549 lines
18 KiB
Vue
549 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import type { SbaPlayer, LineupPlayerRequest, SubmitLineupsRequest } from '~/types'
|
|
import ActionButton from '~/components/UI/ActionButton.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const config = useRuntimeConfig()
|
|
|
|
// Game and team data
|
|
const gameId = ref(route.params.id as string)
|
|
const homeTeamId = ref<number | null>(null)
|
|
const awayTeamId = ref<number | null>(null)
|
|
const season = ref(3)
|
|
|
|
// Active tab (home or away)
|
|
type TeamTab = 'home' | 'away'
|
|
const activeTab = ref<TeamTab>('away') // Away bats first
|
|
|
|
// Roster data
|
|
const homeRoster = ref<SbaPlayer[]>([])
|
|
const awayRoster = ref<SbaPlayer[]>([])
|
|
const loadingRoster = ref(false)
|
|
const submittingLineups = ref(false)
|
|
|
|
// Lineup state - 10 slots each (1-9 batting, 10 pitcher)
|
|
interface LineupSlot {
|
|
player: SbaPlayer | null
|
|
position: string | null
|
|
battingOrder: number | null // 1-9 for batters, null for pitcher
|
|
}
|
|
|
|
const homeLineup = ref<LineupSlot[]>(Array(10).fill(null).map((_, i) => ({
|
|
player: null,
|
|
position: null,
|
|
battingOrder: i < 9 ? i + 1 : null // Slots 0-8 are batting order 1-9, slot 9 is pitcher
|
|
})))
|
|
|
|
const awayLineup = ref<LineupSlot[]>(Array(10).fill(null).map((_, i) => ({
|
|
player: null,
|
|
position: null,
|
|
battingOrder: i < 9 ? i + 1 : null
|
|
})))
|
|
|
|
// Available roster for dragging (players not in lineup)
|
|
const availableHomeRoster = computed(() => {
|
|
const usedPlayerIds = new Set(homeLineup.value.filter(s => s.player).map(s => s.player!.id))
|
|
return homeRoster.value.filter(p => !usedPlayerIds.has(p.id))
|
|
})
|
|
|
|
const availableAwayRoster = computed(() => {
|
|
const usedPlayerIds = new Set(awayLineup.value.filter(s => s.player).map(s => s.player!.id))
|
|
return awayRoster.value.filter(p => !usedPlayerIds.has(p.id))
|
|
})
|
|
|
|
// Current lineup based on active tab
|
|
const currentLineup = computed(() => activeTab.value === 'home' ? homeLineup.value : awayLineup.value)
|
|
const currentRoster = computed(() => activeTab.value === 'home' ? availableHomeRoster.value : availableAwayRoster.value)
|
|
|
|
// Slot 10 (pitcher) should be disabled if P is selected in batting order
|
|
const pitcherSlotDisabled = computed(() => {
|
|
const lineup = currentLineup.value
|
|
return lineup.slice(0, 9).some(slot => slot.position === 'P')
|
|
})
|
|
|
|
// Validation
|
|
const duplicatePositions = computed(() => {
|
|
const lineup = currentLineup.value
|
|
const positionCounts = new Map<string, number>()
|
|
|
|
lineup.forEach(slot => {
|
|
if (slot.player && slot.position) {
|
|
positionCounts.set(slot.position, (positionCounts.get(slot.position) || 0) + 1)
|
|
}
|
|
})
|
|
|
|
return Array.from(positionCounts.entries())
|
|
.filter(([_, count]) => count > 1)
|
|
.map(([pos, _]) => pos)
|
|
})
|
|
|
|
const validationErrors = computed(() => {
|
|
const errors: string[] = []
|
|
|
|
// Check both lineups
|
|
const homeErrors = validateLineup(homeLineup.value, 'Home')
|
|
const awayErrors = validateLineup(awayLineup.value, 'Away')
|
|
|
|
return [...homeErrors, ...awayErrors]
|
|
})
|
|
|
|
function validateLineup(lineup: LineupSlot[], teamName: string): string[] {
|
|
const errors: string[] = []
|
|
|
|
// Check if P is in batting order
|
|
const pitcherInBattingOrder = lineup.slice(0, 9).some(s => s.position === 'P')
|
|
const requiredSlots = pitcherInBattingOrder ? 9 : 10
|
|
|
|
// Check if all slots filled
|
|
const filledSlots = lineup.filter(s => s.player).length
|
|
if (filledSlots < requiredSlots) {
|
|
errors.push(`${teamName}: Fill all ${requiredSlots} lineup slots`)
|
|
}
|
|
|
|
// Check for missing positions
|
|
const missingPositions = lineup.filter(s => s.player && !s.position).length
|
|
if (missingPositions > 0) {
|
|
errors.push(`${teamName}: ${missingPositions} player${missingPositions > 1 ? 's' : ''} missing position`)
|
|
}
|
|
|
|
// Check for duplicate positions
|
|
const positionCounts = new Map<string, number>()
|
|
lineup.forEach(slot => {
|
|
if (slot.player && slot.position) {
|
|
positionCounts.set(slot.position, (positionCounts.get(slot.position) || 0) + 1)
|
|
}
|
|
})
|
|
|
|
const duplicates = Array.from(positionCounts.entries())
|
|
.filter(([_, count]) => count > 1)
|
|
.map(([pos, _]) => pos)
|
|
|
|
if (duplicates.length > 0) {
|
|
errors.push(`${teamName}: Duplicate positions - ${duplicates.join(', ')}`)
|
|
}
|
|
|
|
return errors
|
|
}
|
|
|
|
const canSubmit = computed(() => {
|
|
return validationErrors.value.length === 0
|
|
})
|
|
|
|
// Get player's available positions
|
|
function getPlayerPositions(player: SbaPlayer): string[] {
|
|
const positions: string[] = []
|
|
for (let i = 1; i <= 8; i++) {
|
|
const pos = player[`pos_${i}` as keyof SbaPlayer]
|
|
if (pos && typeof pos === 'string') {
|
|
positions.push(pos)
|
|
}
|
|
}
|
|
// Always add DH as an option for all players
|
|
if (!positions.includes('DH')) {
|
|
positions.push('DH')
|
|
}
|
|
return positions
|
|
}
|
|
|
|
// Drag handlers
|
|
function handleRosterDrag(player: SbaPlayer, toSlotIndex: number, fromSlotIndex?: number) {
|
|
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
|
|
|
|
// If dragging from another slot, swap or move
|
|
if (fromSlotIndex !== undefined && fromSlotIndex !== toSlotIndex) {
|
|
const fromSlot = lineup[fromSlotIndex]
|
|
const toSlot = lineup[toSlotIndex]
|
|
|
|
// Swap players
|
|
const tempPlayer = toSlot.player
|
|
const tempPosition = toSlot.position
|
|
|
|
toSlot.player = fromSlot.player
|
|
toSlot.position = fromSlot.position
|
|
|
|
fromSlot.player = tempPlayer
|
|
fromSlot.position = tempPosition
|
|
} else if (fromSlotIndex === undefined) {
|
|
// Adding from roster pool
|
|
lineup[toSlotIndex].player = player
|
|
|
|
// For pitcher slot (index 9), always use 'P'
|
|
if (toSlotIndex === 9) {
|
|
lineup[toSlotIndex].position = 'P'
|
|
} else {
|
|
// Auto-suggest first available position
|
|
const availablePositions = getPlayerPositions(player)
|
|
if (availablePositions.length > 0) {
|
|
lineup[toSlotIndex].position = availablePositions[0]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function removePlayer(slotIndex: number) {
|
|
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
|
|
lineup[slotIndex].player = null
|
|
lineup[slotIndex].position = null
|
|
}
|
|
|
|
// Fetch game data
|
|
async function fetchGameData() {
|
|
try {
|
|
const response = await fetch(`${config.public.apiUrl}/api/games/${gameId.value}`)
|
|
const data = await response.json()
|
|
homeTeamId.value = data.home_team_id
|
|
awayTeamId.value = data.away_team_id
|
|
} catch (error) {
|
|
console.error('Failed to fetch game data:', error)
|
|
}
|
|
}
|
|
|
|
// Fetch roster for a team
|
|
async function fetchRoster(teamId: number) {
|
|
try {
|
|
loadingRoster.value = true
|
|
const response = await fetch(`${config.public.apiUrl}/api/teams/${teamId}/roster?season=${season.value}`)
|
|
const data = await response.json()
|
|
return data.players as SbaPlayer[]
|
|
} catch (error) {
|
|
console.error(`Failed to fetch roster for team ${teamId}:`, error)
|
|
return []
|
|
} finally {
|
|
loadingRoster.value = false
|
|
}
|
|
}
|
|
|
|
// Submit lineups
|
|
async function submitLineups() {
|
|
if (!canSubmit.value || submittingLineups.value) return
|
|
|
|
submittingLineups.value = true
|
|
|
|
// Build request
|
|
const homeLineupRequest: LineupPlayerRequest[] = homeLineup.value
|
|
.filter(s => s.player)
|
|
.map(s => ({
|
|
player_id: s.player!.id,
|
|
position: s.position!,
|
|
batting_order: s.battingOrder
|
|
}))
|
|
|
|
const awayLineupRequest: LineupPlayerRequest[] = awayLineup.value
|
|
.filter(s => s.player)
|
|
.map(s => ({
|
|
player_id: s.player!.id,
|
|
position: s.position!,
|
|
batting_order: s.battingOrder
|
|
}))
|
|
|
|
const request: SubmitLineupsRequest = {
|
|
home_lineup: homeLineupRequest,
|
|
away_lineup: awayLineupRequest
|
|
}
|
|
|
|
console.log('Submitting lineup request:', JSON.stringify(request, null, 2))
|
|
|
|
try {
|
|
const response = await fetch(`${config.public.apiUrl}/api/games/${gameId.value}/lineups`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(request)
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
console.error('Lineup submission error:', error)
|
|
|
|
// Handle Pydantic validation errors
|
|
if (error.detail && Array.isArray(error.detail)) {
|
|
const messages = error.detail.map((err: any) => {
|
|
if (err.loc) {
|
|
const location = err.loc.join(' → ')
|
|
return `${location}: ${err.msg}`
|
|
}
|
|
return err.msg || JSON.stringify(err)
|
|
})
|
|
throw new Error(`Validation errors:\n${messages.join('\n')}`)
|
|
}
|
|
|
|
throw new Error(typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
|
|
}
|
|
|
|
const result = await response.json()
|
|
console.log('Lineups submitted:', result)
|
|
|
|
// Redirect to game page
|
|
router.push(`/games/${gameId.value}`)
|
|
} catch (error) {
|
|
console.error('Failed to submit lineups:', error)
|
|
alert(error instanceof Error ? error.message : 'Failed to submit lineups')
|
|
} finally {
|
|
submittingLineups.value = false
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
onMounted(async () => {
|
|
await fetchGameData()
|
|
|
|
if (homeTeamId.value) {
|
|
homeRoster.value = await fetchRoster(homeTeamId.value)
|
|
}
|
|
if (awayTeamId.value) {
|
|
awayRoster.value = await fetchRoster(awayTeamId.value)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-gray-900 text-white p-4">
|
|
<div class="max-w-6xl mx-auto">
|
|
<!-- Header -->
|
|
<div class="mb-6">
|
|
<h1 class="text-3xl font-bold mb-2">Build Your Lineup</h1>
|
|
<p class="text-gray-400">Drag players from the roster to the lineup slots</p>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="flex gap-2 mb-6 border-b border-gray-700">
|
|
<button
|
|
:class="[
|
|
'px-6 py-3 font-semibold transition-colors',
|
|
activeTab === 'away'
|
|
? 'bg-blue-600 text-white border-b-2 border-blue-500'
|
|
: 'text-gray-400 hover:text-white'
|
|
]"
|
|
@click="activeTab = 'away'"
|
|
>
|
|
Away Lineup
|
|
</button>
|
|
<button
|
|
:class="[
|
|
'px-6 py-3 font-semibold transition-colors',
|
|
activeTab === 'home'
|
|
? 'bg-blue-600 text-white border-b-2 border-blue-500'
|
|
: 'text-gray-400 hover:text-white'
|
|
]"
|
|
@click="activeTab = 'home'"
|
|
>
|
|
Home Lineup
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Loading state -->
|
|
<div v-if="loadingRoster" class="text-center py-12">
|
|
<div class="text-xl">Loading roster...</div>
|
|
</div>
|
|
|
|
<!-- Main content -->
|
|
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<!-- Roster Pool (Sticky on desktop) -->
|
|
<div class="lg:col-span-1">
|
|
<div class="lg:sticky lg:top-4">
|
|
<h2 class="text-xl font-bold mb-4">Available Players</h2>
|
|
<div class="bg-gray-800 rounded-lg p-4 space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
|
<div
|
|
v-for="player in currentRoster"
|
|
:key="player.id"
|
|
draggable="true"
|
|
class="bg-gray-700 rounded p-3 cursor-move hover:bg-gray-600 transition-colors"
|
|
@dragstart="(e) => e.dataTransfer?.setData('player', JSON.stringify(player))"
|
|
>
|
|
<div class="font-semibold">{{ player.name }}</div>
|
|
<div class="text-sm text-gray-400">
|
|
{{ getPlayerPositions(player).join(', ') || 'No positions' }}
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="currentRoster.length === 0" class="text-gray-500 text-center py-4">
|
|
All players assigned
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lineup Slots -->
|
|
<div class="lg:col-span-2">
|
|
<h2 class="text-xl font-bold mb-4">Lineup</h2>
|
|
|
|
<!-- Batting order slots (1-9) -->
|
|
<div class="space-y-3 mb-6">
|
|
<div
|
|
v-for="(slot, index) in currentLineup.slice(0, 9)"
|
|
:key="index"
|
|
class="bg-gray-800 rounded-lg p-4"
|
|
>
|
|
<div class="flex items-center gap-4">
|
|
<!-- Batting order number -->
|
|
<div class="text-2xl font-bold text-gray-500 w-8">
|
|
{{ index + 1 }}
|
|
</div>
|
|
|
|
<!-- Player slot -->
|
|
<div
|
|
class="flex-1"
|
|
@drop.prevent="(e) => {
|
|
const playerData = e.dataTransfer?.getData('player')
|
|
const fromSlotData = e.dataTransfer?.getData('fromSlot')
|
|
if (playerData) {
|
|
const player = JSON.parse(playerData) as SbaPlayer
|
|
const fromSlot = fromSlotData ? parseInt(fromSlotData) : undefined
|
|
handleRosterDrag(player, index, fromSlot)
|
|
}
|
|
}"
|
|
@dragover.prevent
|
|
>
|
|
<div
|
|
v-if="slot.player"
|
|
class="bg-blue-900 rounded p-3 cursor-move"
|
|
draggable="true"
|
|
@dragstart="(e) => {
|
|
e.dataTransfer?.setData('player', JSON.stringify(slot.player))
|
|
e.dataTransfer?.setData('fromSlot', index.toString())
|
|
}"
|
|
>
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex-1">
|
|
<div class="font-semibold">{{ slot.player.name }}</div>
|
|
<div class="text-sm text-gray-400 mt-1">
|
|
Available: {{ getPlayerPositions(slot.player).join(', ') }}
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="text-red-400 hover:text-red-300 ml-2"
|
|
@click="removePlayer(index)"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else class="border-2 border-dashed border-gray-600 rounded p-4 text-center text-gray-500">
|
|
Drop player here
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Position selector -->
|
|
<div class="w-32">
|
|
<select
|
|
v-if="slot.player"
|
|
v-model="slot.position"
|
|
:class="[
|
|
'w-full bg-gray-700 border rounded px-3 py-2',
|
|
duplicatePositions.includes(slot.position || '')
|
|
? 'border-red-500 font-bold text-red-400'
|
|
: 'border-gray-600'
|
|
]"
|
|
>
|
|
<option :value="null">Position</option>
|
|
<option
|
|
v-for="pos in getPlayerPositions(slot.player)"
|
|
:key="pos"
|
|
:value="pos"
|
|
>
|
|
{{ pos }}
|
|
</option>
|
|
</select>
|
|
<div v-else class="text-gray-600 text-sm text-center">
|
|
-
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pitcher slot (10) -->
|
|
<div class="mb-6">
|
|
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
|
|
Pitcher (Non-Batting)
|
|
<span v-if="pitcherSlotDisabled" class="text-sm text-yellow-400">
|
|
(Disabled - Pitcher batting)
|
|
</span>
|
|
</h3>
|
|
<div
|
|
:class="[
|
|
'bg-gray-800 rounded-lg p-4',
|
|
pitcherSlotDisabled && 'opacity-50 pointer-events-none'
|
|
]"
|
|
>
|
|
<div class="flex items-center gap-4">
|
|
<div class="text-2xl font-bold text-gray-500 w-8">P</div>
|
|
|
|
<div
|
|
class="flex-1"
|
|
@drop.prevent="(e) => {
|
|
const playerData = e.dataTransfer?.getData('player')
|
|
const fromSlotData = e.dataTransfer?.getData('fromSlot')
|
|
if (playerData) {
|
|
const player = JSON.parse(playerData) as SbaPlayer
|
|
const fromSlot = fromSlotData ? parseInt(fromSlotData) : undefined
|
|
handleRosterDrag(player, 9, fromSlot)
|
|
}
|
|
}"
|
|
@dragover.prevent
|
|
>
|
|
<div
|
|
v-if="currentLineup[9].player"
|
|
class="bg-blue-900 rounded p-3 cursor-move"
|
|
draggable="true"
|
|
@dragstart="(e) => {
|
|
e.dataTransfer?.setData('player', JSON.stringify(currentLineup[9].player))
|
|
e.dataTransfer?.setData('fromSlot', '9')
|
|
}"
|
|
>
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex-1">
|
|
<div class="font-semibold">{{ currentLineup[9].player.name }}</div>
|
|
<div class="text-sm text-gray-400 mt-1">
|
|
Available: {{ getPlayerPositions(currentLineup[9].player).join(', ') }}
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="text-red-400 hover:text-red-300 ml-2"
|
|
@click="removePlayer(9)"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else class="border-2 border-dashed border-gray-600 rounded p-4 text-center text-gray-500">
|
|
Drop pitcher here
|
|
</div>
|
|
</div>
|
|
|
|
<div class="w-32">
|
|
<div class="text-gray-600 text-sm text-center font-semibold">P</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Validation errors -->
|
|
<div v-if="validationErrors.length > 0" class="bg-red-900/30 border border-red-500 rounded-lg p-4 mb-4">
|
|
<div class="font-semibold mb-2">Please fix the following errors:</div>
|
|
<ul class="list-disc list-inside space-y-1">
|
|
<li v-for="error in validationErrors" :key="error" class="text-sm">
|
|
{{ error }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Submit button -->
|
|
<ActionButton
|
|
variant="success"
|
|
size="lg"
|
|
full-width
|
|
:disabled="!canSubmit"
|
|
:loading="submittingLineups"
|
|
@click="submitLineups"
|
|
>
|
|
{{ submittingLineups ? 'Submitting Lineups...' : (canSubmit ? 'Submit Lineups & Start Game' : 'Complete Both Lineups to Continue') }}
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|