Files
servesync-demo/frontend/src/views/EventBuilder.vue
Nemo 4421f61c24 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
2026-03-19 07:35:51 +08:00

279 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>