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,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>