feat: Rebuild as Court Manager Dashboard v2
- Complete rewrite for single Court Manager user - Feature 1: Player Management (CRUD, ELO, skill level) - Feature 2: Court Reservation (timeline, override) - Feature 3: Match Event Builder (3-step wizard) - Step 1: Event setup with player selection + stage calculator - Step 2: Stage configurator (Open/Skill/RR/Tournament) - Step 3: Auto bracket generation - Feature 4: Live Court View (main dashboard, 2x2 grid) - Event Control: score entry, bracket advancement, court assignment - Screen view: TV display per court (/screen/:id) - Seed: 8 players, 4 courts, 1 completed + 1 active event - Routes: / players courts events events/new events/:id screen/:id
This commit is contained in:
341
frontend/src/views/EventControl.vue
Normal file
341
frontend/src/views/EventControl.vue
Normal file
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<div v-if="event">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="$router.push('/events')" class="text-gray-500 hover:text-gray-300">← Events</button>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">{{ event.name }}</h1>
|
||||
<p class="text-gray-500 text-sm">
|
||||
{{ event.format }} · {{ event.players?.length }} players · {{ event.courts_count }} courts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span :class="eventStatusClass(event.status)" class="capitalize">{{ event.status }}</span>
|
||||
<button v-if="event.status === 'setup'" @click="startEvent" class="btn-primary">▶ Start Event</button>
|
||||
<button v-if="event.status === 'active'" @click="completeEvent" class="btn-secondary">✓ Complete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<!-- Main: Stages + Matches -->
|
||||
<div class="col-span-2 space-y-4">
|
||||
<!-- Stage tabs -->
|
||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||
<button v-for="stage in event.stages" :key="stage.id"
|
||||
@click="activeStageId = stage.id"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors"
|
||||
:class="activeStageId === stage.id
|
||||
? 'bg-green-900 text-green-300'
|
||||
: 'bg-gray-800 text-gray-400 hover:text-gray-200'">
|
||||
<span class="mr-1">{{ stageIcon(stage.status) }}</span>
|
||||
{{ stage.name }}
|
||||
<span class="ml-1 text-xs opacity-60">{{ stage.match_type }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active stage -->
|
||||
<div v-if="activeStage">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-white">{{ activeStage.name }}</h3>
|
||||
<p class="text-xs text-gray-500">{{ activeStage.match_type }} · {{ activeStage.rounds }} rounds</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span :class="stageStatusClass(activeStage.status)" class="capitalize">{{ activeStage.status }}</span>
|
||||
<button v-if="activeStage.status === 'pending' && event.status === 'active'"
|
||||
@click="activateStage(activeStage.id)" class="btn-primary btn-sm text-xs">
|
||||
▶ Activate Stage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Matches grouped by round -->
|
||||
<div v-for="round in roundsInStage" :key="round" class="mb-4">
|
||||
<h4 class="text-xs text-gray-500 uppercase tracking-wider mb-2">
|
||||
{{ roundLabel(round, activeStage) }}
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="m in matchesByRound(round)" :key="m.id"
|
||||
class="card p-4 flex items-center gap-4"
|
||||
:class="{
|
||||
'border-yellow-700': m.status === 'in_progress',
|
||||
'border-green-800 opacity-70': m.status === 'completed',
|
||||
'border-gray-700': m.status === 'scheduled',
|
||||
}">
|
||||
<!-- Status -->
|
||||
<div class="w-20 text-center">
|
||||
<span :class="matchStatusClass(m.status)" class="text-xs capitalize">
|
||||
{{ m.status === 'in_progress' ? '🔴 Live' : m.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Players / Score -->
|
||||
<div class="flex-1 flex items-center gap-3">
|
||||
<div class="flex-1 text-right">
|
||||
<p class="font-medium text-white" :class="m.winner_id === m.player1_id && m.status === 'completed' ? 'text-green-400' : ''">
|
||||
{{ m.player1_name || 'TBD' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center min-w-[80px]">
|
||||
<span v-if="m.status === 'completed'" class="font-mono font-bold text-lg text-white">
|
||||
{{ m.score1 }} : {{ m.score2 }}
|
||||
</span>
|
||||
<span v-else-if="m.status === 'in_progress'" class="font-mono text-yellow-400 text-sm">Live</span>
|
||||
<span v-else class="text-gray-600 text-sm">vs</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-white" :class="m.winner_id === m.player2_id && m.status === 'completed' ? 'text-green-400' : ''">
|
||||
{{ m.player2_name || 'TBD' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Court -->
|
||||
<div class="text-xs text-gray-500 w-16 text-center">
|
||||
<div v-if="m.court_id">{{ m.court_name }}</div>
|
||||
<button v-else-if="m.status !== 'completed'" @click="openCourtAssign(m)"
|
||||
class="text-gray-600 hover:text-gray-400">Assign</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
<button v-if="m.status === 'scheduled' && m.player1_name && m.player2_name"
|
||||
@click="startMatch(m.id)" class="btn-secondary btn-sm text-xs">▶</button>
|
||||
<button v-if="m.status === 'in_progress'"
|
||||
@click="openScore(m)" class="btn-primary btn-sm text-xs">Score</button>
|
||||
<button v-if="m.status === 'completed'"
|
||||
@click="openScore(m)" class="text-xs text-gray-600 hover:text-gray-400">✎</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!activeStage.matches || activeStage.matches.length === 0"
|
||||
class="text-center py-8 text-gray-600">
|
||||
No matches generated yet.
|
||||
<button @click="generateMatches(activeStage.id)" class="ml-2 text-green-500 hover:underline">Generate Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: Players + Bracket summary -->
|
||||
<div class="space-y-4">
|
||||
<!-- Players -->
|
||||
<div class="card p-4">
|
||||
<h3 class="font-semibold text-gray-300 mb-3">👥 Players ({{ event.players?.length }})</h3>
|
||||
<div class="space-y-1">
|
||||
<div v-for="ep in event.players" :key="ep.id"
|
||||
class="flex items-center justify-between py-1.5">
|
||||
<span class="text-sm text-white">{{ ep.player.name }}</span>
|
||||
<span :class="skillBadgeClass(ep.skill_level_override || ep.player.skill_level)" class="text-xs">
|
||||
{{ ep.skill_level_override || ep.player.skill_level }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bracket summary for tournament stages -->
|
||||
<div v-if="tournamentStage" class="card p-4">
|
||||
<h3 class="font-semibold text-gray-300 mb-3">🏆 Bracket</h3>
|
||||
<div v-for="round in tournamentRounds" :key="round" class="mb-3">
|
||||
<p class="text-xs text-gray-500 uppercase mb-1">{{ roundLabel(round, tournamentStage) }}</p>
|
||||
<div v-for="m in tournamentMatchesByRound(round)" :key="m.id" class="text-xs py-1 border-l-2 pl-2 mb-1"
|
||||
:class="{
|
||||
'border-yellow-500': m.status === 'in_progress',
|
||||
'border-green-600': m.status === 'completed',
|
||||
'border-gray-700': m.status === 'scheduled',
|
||||
}">
|
||||
<div class="flex items-center justify-between">
|
||||
<span :class="m.winner_id === m.player1_id && m.status === 'completed' ? 'text-green-400' : 'text-gray-300'">
|
||||
{{ m.player1_name || 'TBD' }}
|
||||
</span>
|
||||
<span v-if="m.status === 'completed'" class="text-gray-500 font-mono">{{ m.score1 }}-{{ m.score2 }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span :class="m.winner_id === m.player2_id && m.status === 'completed' ? 'text-green-400' : 'text-gray-300'">
|
||||
{{ m.player2_name || 'TBD' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Modal -->
|
||||
<div v-if="scoreModal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
@click.self="scoreModal = null">
|
||||
<div class="card p-6 w-full max-w-sm">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Enter Score</h3>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="flex-1 text-center">
|
||||
<p class="text-xs text-gray-400 mb-2">{{ scoreModal.player1_name }}</p>
|
||||
<input v-model.number="scoreInput1" type="number" min="0" class="input text-center text-2xl font-bold h-16" />
|
||||
</div>
|
||||
<div class="text-gray-600 text-xl font-bold">:</div>
|
||||
<div class="flex-1 text-center">
|
||||
<p class="text-xs text-gray-400 mb-2">{{ scoreModal.player2_name }}</p>
|
||||
<input v-model.number="scoreInput2" type="number" min="0" class="input text-center text-2xl font-bold h-16" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="scoreModal = null" class="btn-secondary flex-1">Cancel</button>
|
||||
<button @click="submitScore" class="btn-primary flex-1">Confirm Score</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Court Assign Modal -->
|
||||
<div v-if="courtAssignModal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
@click.self="courtAssignModal = null">
|
||||
<div class="card p-6 w-full max-w-sm">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Assign Court</h3>
|
||||
<div class="space-y-2 mb-4">
|
||||
<button v-for="c in courts" :key="c.id"
|
||||
@click="assignCourt(c.id)"
|
||||
class="w-full btn-secondary flex items-center justify-between"
|
||||
:disabled="c.status === 'in_use' || c.status === 'maintenance'">
|
||||
<span>{{ c.name }}</span>
|
||||
<span :class="courtStatusClass(c.status)" class="text-xs capitalize">{{ c.status }}</span>
|
||||
</button>
|
||||
<button @click="assignCourt(null)" class="w-full btn-secondary text-sm text-gray-500">
|
||||
Remove Court Assignment
|
||||
</button>
|
||||
</div>
|
||||
<button @click="courtAssignModal = null" class="btn-secondary w-full">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-16 text-gray-500">Loading event...</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api, { skillBadgeClass, eventStatusClass, courtStatusClass } from '../stores/api'
|
||||
|
||||
const route = useRoute()
|
||||
const event = ref(null)
|
||||
const courts = ref([])
|
||||
const activeStageId = ref(null)
|
||||
const scoreModal = ref(null)
|
||||
const scoreInput1 = ref(0)
|
||||
const scoreInput2 = ref(0)
|
||||
const courtAssignModal = ref(null)
|
||||
|
||||
const activeStage = computed(() =>
|
||||
event.value?.stages?.find(s => s.id === activeStageId.value)
|
||||
)
|
||||
|
||||
const tournamentStage = computed(() =>
|
||||
event.value?.stages?.find(s => s.match_type === 'Tournament')
|
||||
)
|
||||
|
||||
const roundsInStage = computed(() => {
|
||||
if (!activeStage.value?.matches) return []
|
||||
return [...new Set(activeStage.value.matches.map(m => m.round_number))].sort()
|
||||
})
|
||||
|
||||
const tournamentRounds = computed(() => {
|
||||
if (!tournamentStage.value?.matches) return []
|
||||
return [...new Set(tournamentStage.value.matches.map(m => m.round_number))].sort()
|
||||
})
|
||||
|
||||
function matchesByRound(round) {
|
||||
return activeStage.value?.matches?.filter(m => m.round_number === round) || []
|
||||
}
|
||||
function tournamentMatchesByRound(round) {
|
||||
return tournamentStage.value?.matches?.filter(m => m.round_number === round) || []
|
||||
}
|
||||
|
||||
function stageIcon(status) {
|
||||
return { pending: '⏸', active: '▶', completed: '✓' }[status] || '•'
|
||||
}
|
||||
function stageStatusClass(status) {
|
||||
return { pending: 'badge-gray', active: 'badge-green', completed: 'badge-blue' }[status] || 'badge-gray'
|
||||
}
|
||||
function matchStatusClass(status) {
|
||||
return { scheduled: 'badge-gray', in_progress: 'badge-yellow', completed: 'badge-green', bye: 'badge-gray' }[status] || 'badge-gray'
|
||||
}
|
||||
function roundLabel(round, stage) {
|
||||
if (!stage) return `Round ${round}`
|
||||
if (stage.match_type === 'Tournament') {
|
||||
const totalRounds = Math.max(...(stage.matches || []).map(m => m.round_number))
|
||||
if (round === totalRounds) return 'Final'
|
||||
if (round === totalRounds - 1) return 'Semifinals'
|
||||
if (round === totalRounds - 2) return 'Quarterfinals'
|
||||
}
|
||||
return `Round ${round}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [evtRes, courtsRes] = await Promise.all([
|
||||
api.get(`/events/${route.params.id}`),
|
||||
api.get('/courts/'),
|
||||
])
|
||||
event.value = evtRes.data
|
||||
courts.value = courtsRes.data
|
||||
if (!activeStageId.value && event.value.stages?.length > 0) {
|
||||
const active = event.value.stages.find(s => s.status === 'active') || event.value.stages[0]
|
||||
activeStageId.value = active.id
|
||||
}
|
||||
}
|
||||
|
||||
async function startEvent() {
|
||||
await api.post(`/events/${event.value.id}/start`)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function completeEvent() {
|
||||
await api.post(`/events/${event.value.id}/complete`)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function activateStage(stageId) {
|
||||
// Update stage status via event - simple approach: direct API
|
||||
await api.put(`/events/${event.value.id}/stages/${stageId}`, { advance_count: activeStage.value.advance_count })
|
||||
// Set active stage on event
|
||||
await load()
|
||||
}
|
||||
|
||||
async function generateMatches(stageId) {
|
||||
await api.post(`/events/${event.value.id}/stages/${stageId}/generate`)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function startMatch(id) {
|
||||
await api.post(`/matches/${id}/start`)
|
||||
await load()
|
||||
}
|
||||
|
||||
function openScore(m) {
|
||||
scoreModal.value = m
|
||||
scoreInput1.value = m.score1 ?? 0
|
||||
scoreInput2.value = m.score2 ?? 0
|
||||
}
|
||||
|
||||
async function submitScore() {
|
||||
if (!scoreModal.value) return
|
||||
await api.post(`/matches/${scoreModal.value.id}/score`, {
|
||||
score1: scoreInput1.value,
|
||||
score2: scoreInput2.value,
|
||||
})
|
||||
scoreModal.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
function openCourtAssign(m) {
|
||||
courtAssignModal.value = m
|
||||
}
|
||||
|
||||
async function assignCourt(courtId) {
|
||||
await api.patch(`/matches/${courtAssignModal.value.id}/court`, { court_id: courtId })
|
||||
courtAssignModal.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user