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:
Nemo
2026-03-19 07:35:51 +08:00
parent e455accc7e
commit 4421f61c24
56 changed files with 2949 additions and 4209 deletions

View File

@@ -0,0 +1,278 @@
<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>