- 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
279 lines
11 KiB
Vue
279 lines
11 KiB
Vue
<template>
|
||
<div class="max-w-3xl mx-auto">
|
||
<!-- Header -->
|
||
<div class="flex items-center gap-4 mb-6">
|
||
<button @click="$router.push('/events')" class="text-gray-500 hover:text-gray-300">← Back</button>
|
||
<div>
|
||
<h1 class="text-2xl font-bold text-white">New Match Event</h1>
|
||
<p class="text-gray-500 text-sm">Match Event Builder</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step indicator -->
|
||
<div class="flex items-center mb-8">
|
||
<div v-for="(s, i) in steps" :key="i" class="flex items-center">
|
||
<div class="flex items-center gap-2">
|
||
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-colors"
|
||
:class="step > i ? 'bg-green-600 text-white' : step === i ? 'bg-green-900 border-2 border-green-500 text-green-300' : 'bg-gray-800 text-gray-500'">
|
||
{{ step > i ? '✓' : i + 1 }}
|
||
</div>
|
||
<span class="text-sm" :class="step === i ? 'text-white font-medium' : 'text-gray-500'">{{ s }}</span>
|
||
</div>
|
||
<div v-if="i < steps.length - 1" class="h-px flex-1 bg-gray-800 mx-4"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 1: Event Setup -->
|
||
<div v-if="step === 0" class="space-y-6">
|
||
<div class="card p-6">
|
||
<h2 class="text-lg font-bold text-white mb-4">Event Setup</h2>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="label">Event Name *</label>
|
||
<input v-model="form.name" class="input" placeholder="e.g., Summer Singles Open 2026" />
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="label">Match Format</label>
|
||
<div class="flex gap-2">
|
||
<button v-for="f in ['Singles', 'Doubles']" :key="f"
|
||
@click="form.format = f"
|
||
:class="form.format === f ? 'btn-primary' : 'btn-secondary'"
|
||
class="btn flex-1">{{ f }}</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="label">Courts Available</label>
|
||
<input v-model.number="form.courts_count" type="number" min="1" max="10" class="input" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Player selection -->
|
||
<div class="card p-6">
|
||
<h2 class="text-lg font-bold text-white mb-4">Add Players ({{ selectedPlayers.length }} selected)</h2>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<div v-for="p in allPlayers" :key="p.id"
|
||
@click="togglePlayer(p)"
|
||
class="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors border"
|
||
:class="isSelected(p.id)
|
||
? 'bg-green-900/30 border-green-700'
|
||
: 'bg-gray-800/50 border-gray-800 hover:border-gray-700'">
|
||
<div class="w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs transition-colors"
|
||
:class="isSelected(p.id) ? 'bg-green-500 border-green-500 text-white' : 'border-gray-600'">
|
||
{{ isSelected(p.id) ? '✓' : '' }}
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<p class="text-sm font-medium text-white truncate">{{ p.name }}</p>
|
||
<p class="text-xs text-gray-500">{{ p.skill_level }} · ELO {{ Math.round(p.elo) }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Doubles warning -->
|
||
<div v-if="form.format === 'Doubles' && selectedPlayers.length % 2 !== 0"
|
||
class="mt-3 p-3 bg-yellow-900/30 border border-yellow-700 rounded-lg text-sm text-yellow-300">
|
||
⚠️ Doubles requires an even number of players
|
||
</div>
|
||
|
||
<!-- Stage calculation preview -->
|
||
<div v-if="selectedPlayers.length >= 2 && calculation" class="mt-4 p-4 bg-gray-800 rounded-lg">
|
||
<p class="text-sm text-gray-400 mb-1">📊 Event Preview</p>
|
||
<div class="grid grid-cols-3 gap-3 text-center">
|
||
<div>
|
||
<p class="text-2xl font-bold text-white">{{ calculation.teams }}</p>
|
||
<p class="text-xs text-gray-500">{{ form.format === 'Doubles' ? 'Teams' : 'Players' }}</p>
|
||
</div>
|
||
<div>
|
||
<p class="text-2xl font-bold text-white">{{ calculation.suggested_stages }}</p>
|
||
<p class="text-xs text-gray-500">Suggested Stages</p>
|
||
</div>
|
||
<div>
|
||
<p class="text-2xl font-bold text-white">{{ calculation.rounds_single_elim }}</p>
|
||
<p class="text-xs text-gray-500">Elim Rounds</p>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-green-400 mt-2 text-center">
|
||
Suggested: {{ calculation.stage_names.join(' → ') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-end">
|
||
<button @click="goToStep2"
|
||
:disabled="!form.name || selectedPlayers.length < 2"
|
||
class="btn-primary">
|
||
Next: Configure Stages →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 2: Stage Configurator -->
|
||
<div v-if="step === 1" class="space-y-4">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<h2 class="text-lg font-bold text-white">Configure Stages</h2>
|
||
<button @click="addStage" class="btn-secondary btn-sm">+ Add Stage</button>
|
||
</div>
|
||
|
||
<div v-for="(stage, i) in stages" :key="i" class="card p-5">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center gap-2">
|
||
<div class="w-7 h-7 rounded-full bg-gray-700 flex items-center justify-center text-sm font-bold text-gray-300">
|
||
{{ i + 1 }}
|
||
</div>
|
||
<input v-model="stage.name" class="bg-transparent text-white font-semibold text-lg border-b border-transparent hover:border-gray-600 focus:border-green-500 focus:outline-none px-1" />
|
||
</div>
|
||
<button v-if="stages.length > 1" @click="removeStage(i)" class="text-red-500 hover:text-red-400 text-sm">Remove</button>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="label">Match Type</label>
|
||
<select v-model="stage.match_type" class="input">
|
||
<option>Open Match</option>
|
||
<option>Skill-Based</option>
|
||
<option>Round Robin</option>
|
||
<option>Tournament</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="label">Rounds in this Stage</label>
|
||
<input v-model.number="stage.rounds" type="number" min="1" class="input" />
|
||
</div>
|
||
<div>
|
||
<label class="label">Advance Rule</label>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm text-gray-400">Top</span>
|
||
<input v-model.number="stage.advance_count" type="number" min="0" class="input w-20" />
|
||
<span class="text-sm text-gray-400">advance</span>
|
||
</div>
|
||
<p class="text-xs text-gray-600 mt-1">0 = all players advance</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-3 justify-between">
|
||
<button @click="step = 0" class="btn-secondary">← Back</button>
|
||
<button @click="createEvent" class="btn-primary">
|
||
Generate Event & Brackets →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 3: Running summary / redirect -->
|
||
<div v-if="step === 2" class="card p-8 text-center">
|
||
<p class="text-5xl mb-4">🎉</p>
|
||
<h2 class="text-2xl font-bold text-white mb-2">Event Created!</h2>
|
||
<p class="text-gray-400 mb-6">{{ form.name }} is ready to go.</p>
|
||
<div class="flex gap-3 justify-center">
|
||
<router-link :to="`/events/${createdEventId}`" class="btn-primary">
|
||
🎮 Run the Event →
|
||
</router-link>
|
||
<router-link to="/events" class="btn-secondary">Back to Events</router-link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch, computed, onMounted } from 'vue'
|
||
import api from '../stores/api'
|
||
|
||
const step = ref(0)
|
||
const steps = ['Event Setup', 'Stage Configurator', 'Done!']
|
||
|
||
const form = ref({ name: '', format: 'Singles', courts_count: 2 })
|
||
const allPlayers = ref([])
|
||
const selectedPlayerIds = ref([])
|
||
const calculation = ref(null)
|
||
const createdEventId = ref(null)
|
||
|
||
const stages = ref([
|
||
{ name: 'Open Stage', match_type: 'Open Match', rounds: 2, advance_count: 4 },
|
||
{ name: 'Finals Tournament', match_type: 'Tournament', rounds: 3, advance_count: 0 },
|
||
])
|
||
|
||
const selectedPlayers = computed(() => allPlayers.value.filter(p => selectedPlayerIds.value.includes(p.id)))
|
||
|
||
function isSelected(id) { return selectedPlayerIds.value.includes(id) }
|
||
function togglePlayer(p) {
|
||
const idx = selectedPlayerIds.value.indexOf(p.id)
|
||
if (idx >= 0) selectedPlayerIds.value.splice(idx, 1)
|
||
else selectedPlayerIds.value.push(p.id)
|
||
}
|
||
|
||
async function fetchCalc() {
|
||
if (selectedPlayers.value.length < 2) { calculation.value = null; return }
|
||
const { data } = await api.get('/events/calculate', { params: {
|
||
players: selectedPlayers.value.length,
|
||
format: form.value.format,
|
||
courts: form.value.courts_count,
|
||
}})
|
||
calculation.value = data
|
||
// Auto-update suggested stages
|
||
if (data.stage_names && stages.value.length !== data.suggested_stages) {
|
||
stages.value = data.stage_names.map((name, i) => ({
|
||
name,
|
||
match_type: i === data.stage_names.length - 1 ? 'Tournament' : (i === 0 ? 'Open Match' : 'Skill-Based'),
|
||
rounds: i === data.stage_names.length - 1 ? data.rounds_single_elim : 2,
|
||
advance_count: i === data.stage_names.length - 2 ? Math.ceil(data.teams / 2) : 0,
|
||
}))
|
||
}
|
||
}
|
||
|
||
watch([() => selectedPlayerIds.value.length, () => form.value.format, () => form.value.courts_count], fetchCalc)
|
||
|
||
function goToStep2() {
|
||
step.value = 1
|
||
}
|
||
|
||
function addStage() {
|
||
stages.value.push({ name: `Stage ${stages.value.length + 1}`, match_type: 'Round Robin', rounds: 1, advance_count: 0 })
|
||
}
|
||
|
||
function removeStage(i) {
|
||
stages.value.splice(i, 1)
|
||
}
|
||
|
||
async function createEvent() {
|
||
// 1. Create event
|
||
const { data: event } = await api.post('/events/', {
|
||
name: form.value.name,
|
||
format: form.value.format,
|
||
courts_count: form.value.courts_count,
|
||
})
|
||
createdEventId.value = event.id
|
||
|
||
// 2. Add players
|
||
for (const pid of selectedPlayerIds.value) {
|
||
await api.post(`/events/${event.id}/players`, { player_id: pid })
|
||
}
|
||
|
||
// 3. Create stages + generate brackets
|
||
for (let i = 0; i < stages.value.length; i++) {
|
||
const s = stages.value[i]
|
||
const { data: stage } = await api.post(`/events/${event.id}/stages`, {
|
||
name: s.name,
|
||
match_type: s.match_type === 'Open Match' ? 'Open' : s.match_type,
|
||
rounds: s.rounds,
|
||
advance_count: s.advance_count,
|
||
order: i,
|
||
})
|
||
// Generate matches for this stage
|
||
try {
|
||
await api.post(`/events/${event.id}/stages/${stage.id}/generate`)
|
||
} catch (e) {
|
||
console.warn('Could not generate bracket for stage', stage.name, e.response?.data)
|
||
}
|
||
}
|
||
|
||
step.value = 2
|
||
}
|
||
|
||
onMounted(async () => {
|
||
const { data } = await api.get('/players/')
|
||
allPlayers.value = data
|
||
})
|
||
</script>
|