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:
@@ -1,105 +1,71 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-950">
|
||||
<!-- Navbar - hidden on screen display pages -->
|
||||
<nav v-if="!isScreenDisplay" class="bg-gray-900 border-b border-gray-800 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<router-link to="/" class="flex items-center gap-2">
|
||||
<span class="text-2xl">🏓</span>
|
||||
<span class="text-xl font-bold text-white">Serve<span class="text-green-400">Sync</span></span>
|
||||
<!-- Hide nav on screen view -->
|
||||
<nav v-if="!isScreenView" class="bg-gray-900 border-b border-gray-800 px-4 py-3">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl">🏓</span>
|
||||
<span class="text-xl font-bold text-white">ServeSync</span>
|
||||
<span class="text-xs text-gray-500 ml-1">Court Manager</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<router-link
|
||||
v-for="link in navLinks" :key="link.to"
|
||||
:to="link.to"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="isActive(link.to)
|
||||
? 'bg-green-900 text-green-300'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'"
|
||||
>
|
||||
<span class="mr-1.5">{{ link.icon }}</span>{{ link.label }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-600">{{ timeStr }}</span>
|
||||
<router-link to="/events/new" class="btn-primary btn-sm">
|
||||
+ New Event
|
||||
</router-link>
|
||||
|
||||
<!-- Nav Links -->
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
<router-link v-for="link in navLinks" :key="link.to" :to="link.to"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="$route.path === link.to ? 'bg-green-500/20 text-green-400' : 'text-gray-400 hover:text-white hover:bg-gray-800'">
|
||||
{{ link.icon }} {{ link.label }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Current Player -->
|
||||
<div v-if="store.currentPlayer" class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 bg-gray-800 rounded-lg px-3 py-2">
|
||||
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white"
|
||||
:style="{ backgroundColor: store.currentPlayer.avatar_color }">
|
||||
{{ store.currentPlayer.name[0] }}
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<div class="text-xs text-gray-400">Playing as</div>
|
||||
<div class="text-sm font-medium text-white">{{ store.currentPlayer.name }}</div>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full"
|
||||
:class="tierClass(store.currentPlayer.membership_tier)">
|
||||
{{ store.currentPlayer.elo_rating }} ELO
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Player Switcher -->
|
||||
<select @change="switchPlayer($event.target.value)"
|
||||
class="bg-gray-800 border border-gray-700 text-gray-300 text-xs rounded-lg px-2 py-1.5 focus:outline-none">
|
||||
<option v-for="p in store.players" :key="p.id" :value="p.id"
|
||||
:selected="p.id === store.currentPlayer?.id">
|
||||
{{ p.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile nav -->
|
||||
<div v-if="!isScreenDisplay" class="md:hidden bg-gray-900 border-b border-gray-800 px-4 py-2">
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
<router-link v-for="link in navLinks" :key="link.to" :to="link.to"
|
||||
class="flex-shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="$route.path === link.to ? 'bg-green-500/20 text-green-400' : 'text-gray-400 hover:text-white'">
|
||||
{{ link.icon }} {{ link.label }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<router-view />
|
||||
<main :class="isScreenView ? '' : 'max-w-7xl mx-auto px-4 py-6'">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAppStore } from './stores/app'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
const isScreenDisplay = computed(() => route.name === 'Screen')
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/', label: 'Dashboard', icon: '📊' },
|
||||
{ to: '/courts', label: 'Courts', icon: '🏟️' },
|
||||
{ to: '/matchmaking', label: 'Matchmaking', icon: '⚔️' },
|
||||
{ to: '/tournament', label: 'Tournament', icon: '🏆' },
|
||||
{ to: '/players', label: 'Players', icon: '👥' },
|
||||
{ to: '/', icon: '📺', label: 'Dashboard' },
|
||||
{ to: '/players', icon: '👥', label: 'Players' },
|
||||
{ to: '/courts', icon: '🏟️', label: 'Courts' },
|
||||
{ to: '/events', icon: '🎮', label: 'Events' },
|
||||
]
|
||||
|
||||
const tierClass = (tier) => {
|
||||
const classes = {
|
||||
bronze: 'bg-amber-900/50 text-amber-400',
|
||||
silver: 'bg-gray-700/50 text-gray-300',
|
||||
gold: 'bg-yellow-900/50 text-yellow-400',
|
||||
platinum: 'bg-cyan-900/50 text-cyan-400',
|
||||
elite: 'bg-purple-900/50 text-purple-400',
|
||||
const isScreenView = computed(() => route.path.startsWith('/screen'))
|
||||
const isActive = (path) => {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
const timeStr = ref('')
|
||||
let timer = null
|
||||
|
||||
onMounted(() => {
|
||||
const update = () => {
|
||||
timeStr.value = new Date().toLocaleTimeString('en-PH', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
})
|
||||
}
|
||||
return classes[tier] || 'bg-gray-800 text-gray-400'
|
||||
}
|
||||
|
||||
const switchPlayer = (playerId) => {
|
||||
const player = store.players.find(p => p.id == playerId)
|
||||
if (player) store.setCurrentPlayer(player)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadPlayers()
|
||||
update()
|
||||
timer = setInterval(update, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
import Courts from '../views/Courts.vue'
|
||||
import Matchmaking from '../views/Matchmaking.vue'
|
||||
import Tournament from '../views/Tournament.vue'
|
||||
import ScreenDisplay from '../views/ScreenDisplay.vue'
|
||||
import Players from '../views/Players.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'Dashboard', component: Dashboard },
|
||||
{ path: '/courts', name: 'Courts', component: Courts },
|
||||
{ path: '/matchmaking', name: 'Matchmaking', component: Matchmaking },
|
||||
{ path: '/tournament', name: 'Tournament', component: Tournament },
|
||||
{ path: '/players', name: 'Players', component: Players },
|
||||
{ path: '/screen/:courtId', name: 'Screen', component: ScreenDisplay },
|
||||
{ path: '/', component: () => import('../views/Dashboard.vue'), name: 'dashboard' },
|
||||
{ path: '/players', component: () => import('../views/Players.vue'), name: 'players' },
|
||||
{ path: '/courts', component: () => import('../views/Courts.vue'), name: 'courts' },
|
||||
{ path: '/events', component: () => import('../views/Events.vue'), name: 'events' },
|
||||
{ path: '/events/new', component: () => import('../views/EventBuilder.vue'), name: 'event-new' },
|
||||
{ path: '/events/:id', component: () => import('../views/EventControl.vue'), name: 'event-detail' },
|
||||
{ path: '/screen/:id', component: () => import('../views/Screen.vue'), name: 'screen' },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
53
frontend/src/stores/api.js
Normal file
53
frontend/src/stores/api.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
|
||||
export default api
|
||||
|
||||
export function skillBadgeClass(level) {
|
||||
const map = {
|
||||
'Elite': 'badge-purple',
|
||||
'Advanced': 'badge-blue',
|
||||
'Intermediate': 'badge-green',
|
||||
'Beginner': 'badge-gray',
|
||||
}
|
||||
return map[level] || 'badge-gray'
|
||||
}
|
||||
|
||||
export function courtStatusClass(status) {
|
||||
const map = {
|
||||
'available': 'badge-green',
|
||||
'in_use': 'badge-yellow',
|
||||
'reserved': 'badge-blue',
|
||||
'maintenance': 'badge-red',
|
||||
}
|
||||
return map[status] || 'badge-gray'
|
||||
}
|
||||
|
||||
export function eventStatusClass(status) {
|
||||
const map = {
|
||||
'setup': 'badge-gray',
|
||||
'active': 'badge-green',
|
||||
'completed': 'badge-blue',
|
||||
}
|
||||
return map[status] || 'badge-gray'
|
||||
}
|
||||
|
||||
export function formatElapsed(seconds) {
|
||||
if (!seconds && seconds !== 0) return '--'
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatTime(dt) {
|
||||
if (!dt) return ''
|
||||
const d = new Date(dt)
|
||||
return d.toLocaleTimeString('en-PH', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function formatDate(dt) {
|
||||
if (!dt) return ''
|
||||
const d = new Date(dt)
|
||||
return d.toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const currentPlayer = ref(null)
|
||||
const players = ref([])
|
||||
const courts = ref([])
|
||||
const matches = ref([])
|
||||
const activeTournament = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Set demo player (no auth needed)
|
||||
const setCurrentPlayer = (player) => {
|
||||
currentPlayer.value = player
|
||||
localStorage.setItem('currentPlayerId', player.id)
|
||||
}
|
||||
|
||||
const loadPlayers = async () => {
|
||||
const res = await api.get('/players/')
|
||||
players.value = res.data
|
||||
// Auto-set first player as current if not set
|
||||
if (!currentPlayer.value && players.value.length > 0) {
|
||||
const savedId = localStorage.getItem('currentPlayerId')
|
||||
const saved = savedId ? players.value.find(p => p.id == savedId) : null
|
||||
currentPlayer.value = saved || players.value[0]
|
||||
}
|
||||
return players.value
|
||||
}
|
||||
|
||||
const loadCourts = async () => {
|
||||
const res = await api.get('/courts/')
|
||||
courts.value = res.data
|
||||
return courts.value
|
||||
}
|
||||
|
||||
const loadMatches = async () => {
|
||||
const res = await api.get('/matches/')
|
||||
matches.value = res.data
|
||||
return matches.value
|
||||
}
|
||||
|
||||
const loadLobby = async () => {
|
||||
const res = await api.get('/matches/lobby')
|
||||
return res.data
|
||||
}
|
||||
|
||||
const loadActiveMatches = async () => {
|
||||
const res = await api.get('/matches/active')
|
||||
return res.data
|
||||
}
|
||||
|
||||
const createMatch = async (data) => {
|
||||
const res = await api.post('/matches/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
const joinMatch = async (matchId, playerId, team) => {
|
||||
const res = await api.post(`/matches/${matchId}/join`, { player_id: playerId, team })
|
||||
return res.data
|
||||
}
|
||||
|
||||
const startMatch = async (matchId) => {
|
||||
const res = await api.post(`/matches/${matchId}/start`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
const updateScore = async (matchId, team1Score, team2Score) => {
|
||||
const res = await api.post(`/matches/${matchId}/score`, { team1_score: team1Score, team2_score: team2Score })
|
||||
return res.data
|
||||
}
|
||||
|
||||
const completeMatch = async (matchId, team1Score, team2Score) => {
|
||||
const res = await api.post(`/matches/${matchId}/complete`, { team1_score: team1Score, team2_score: team2Score })
|
||||
return res.data
|
||||
}
|
||||
|
||||
const bookCourt = async (data) => {
|
||||
const res = await api.post('/courts/book', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
const loadTournaments = async () => {
|
||||
const res = await api.get('/tournaments/')
|
||||
return res.data
|
||||
}
|
||||
|
||||
const loadTournament = async (id) => {
|
||||
const res = await api.get(`/tournaments/${id}`)
|
||||
activeTournament.value = res.data
|
||||
return res.data
|
||||
}
|
||||
|
||||
const getLeaderboard = async (limit = 10) => {
|
||||
const res = await api.get(`/players/leaderboard?limit=${limit}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
const getCourtDisplay = async (courtId) => {
|
||||
const res = await api.get(`/screen/court/${courtId}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
const getOverview = async () => {
|
||||
const res = await api.get('/screen/overview')
|
||||
return res.data
|
||||
}
|
||||
|
||||
const completeTournamentMatch = async (tournamentId, matchId, s1, s2) => {
|
||||
const res = await api.post(`/tournaments/${tournamentId}/matches/${matchId}/score`, {
|
||||
team1_score: s1, team2_score: s2
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
return {
|
||||
currentPlayer, players, courts, matches, activeTournament, loading,
|
||||
setCurrentPlayer, loadPlayers, loadCourts, loadMatches,
|
||||
loadLobby, loadActiveMatches, createMatch, joinMatch, startMatch,
|
||||
updateScore, completeMatch, bookCourt, loadTournaments, loadTournament,
|
||||
getLeaderboard, getCourtDisplay, getOverview, completeTournamentMatch,
|
||||
}
|
||||
})
|
||||
@@ -4,39 +4,42 @@
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-950 text-gray-100 font-sans;
|
||||
@apply bg-gray-950 text-gray-100 min-h-screen;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 cursor-pointer;
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-green-500 hover:bg-green-400 text-white;
|
||||
@apply btn bg-green-600 hover:bg-green-500 text-white;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-gray-700 hover:bg-gray-600 text-white;
|
||||
@apply btn bg-gray-700 hover:bg-gray-600 text-gray-100;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-500 text-white;
|
||||
@apply btn bg-red-700 hover:bg-red-600 text-white;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply px-2.5 py-1.5 text-sm;
|
||||
}
|
||||
.card {
|
||||
@apply bg-gray-900 rounded-xl border border-gray-800 p-6;
|
||||
@apply bg-gray-900 border border-gray-800 rounded-xl;
|
||||
}
|
||||
.input {
|
||||
@apply bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-green-500 w-full;
|
||||
}
|
||||
.label {
|
||||
@apply block text-sm text-gray-400 mb-1;
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
.badge-green { @apply badge bg-green-900 text-green-300; }
|
||||
.badge-yellow { @apply badge bg-yellow-900 text-yellow-300; }
|
||||
.badge-red { @apply badge bg-red-900 text-red-300; }
|
||||
.badge-blue { @apply badge bg-blue-900 text-blue-300; }
|
||||
.badge-gray { @apply badge bg-gray-800 text-gray-400; }
|
||||
.badge-purple { @apply badge bg-purple-900 text-purple-300; }
|
||||
}
|
||||
|
||||
.tier-bronze { @apply text-amber-600; }
|
||||
.tier-silver { @apply text-gray-400; }
|
||||
.tier-gold { @apply text-yellow-400; }
|
||||
.tier-platinum { @apply text-cyan-400; }
|
||||
.tier-elite { @apply text-purple-400; }
|
||||
|
||||
.bg-tier-bronze { @apply bg-amber-900/30 border-amber-700/50; }
|
||||
.bg-tier-silver { @apply bg-gray-800/50 border-gray-600/50; }
|
||||
.bg-tier-gold { @apply bg-yellow-900/30 border-yellow-700/50; }
|
||||
.bg-tier-platinum { @apply bg-cyan-900/30 border-cyan-700/50; }
|
||||
.bg-tier-elite { @apply bg-purple-900/30 border-purple-700/50; }
|
||||
|
||||
@@ -1,182 +1,148 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-white">🏟️ Court Reservations</h1>
|
||||
<div class="flex gap-2">
|
||||
<span class="badge bg-green-500/20 text-green-400">{{ availableCourts }} Available</span>
|
||||
<span class="badge bg-red-500/20 text-red-400">{{ occupiedCourts }} Occupied</span>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Court Management</h1>
|
||||
<p class="text-gray-500 text-sm">Manage courts and reservations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Court Grid -->
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div v-for="court in courts" :key="court.id"
|
||||
class="card transition-all hover:border-gray-700"
|
||||
:class="court.current_status === 'occupied' ? 'border-red-800/30' : 'border-green-800/30'">
|
||||
|
||||
<!-- Court Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div v-for="court in courts" :key="court.id" class="card p-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">{{ court.name }}</h2>
|
||||
<div class="text-sm text-gray-400">{{ court.surface_type }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="badge text-sm"
|
||||
:class="court.current_status === 'occupied' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'">
|
||||
{{ court.current_status === 'occupied' ? '🔴 In Use' : '🟢 Available' }}
|
||||
</span>
|
||||
<div class="text-sm text-gray-400 mt-1">₱{{ court.hourly_rate }}/hr</div>
|
||||
<h2 class="text-lg font-bold text-white">{{ court.name }}</h2>
|
||||
<p class="text-xs text-gray-500">{{ court.court_type }}</p>
|
||||
</div>
|
||||
<span :class="courtStatusClass(court.status)" class="capitalize">
|
||||
{{ court.status === 'in_use' ? 'In Use' : court.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="flex flex-wrap gap-1 mb-4">
|
||||
<span v-for="f in court.features.split(',')" :key="f"
|
||||
class="badge bg-gray-800 text-gray-300 text-xs">{{ f.trim() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Active Match -->
|
||||
<div v-if="court.current_match" class="bg-gray-800 rounded-xl p-4 mb-4">
|
||||
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Live Match</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-white">{{ court.current_match.team1?.join(' & ') }}</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl font-bold text-white">{{ court.current_match.team1_score }}</span>
|
||||
<span class="text-gray-500">vs</span>
|
||||
<span class="text-2xl font-bold text-white">{{ court.current_match.team2_score }}</span>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-white text-right">{{ court.current_match.team2?.join(' & ') }}</div>
|
||||
</div>
|
||||
<div class="text-center mt-2">
|
||||
<router-link :to="`/screen/${court.id}`"
|
||||
class="text-xs text-blue-400 hover:underline">📺 View on Screen</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Book Form -->
|
||||
<div v-if="court.current_status === 'available'" class="space-y-3">
|
||||
<div class="text-sm font-medium text-gray-300">Book this court</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Date & Time</label>
|
||||
<input type="datetime-local" v-model="bookingForms[court.id].start_time"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Duration (hours)</label>
|
||||
<select v-model="bookingForms[court.id].duration"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500">
|
||||
<option value="1">1 hour — ₱{{ court.hourly_rate }}</option>
|
||||
<option value="2">2 hours — ₱{{ court.hourly_rate * 2 }}</option>
|
||||
<option value="3">3 hours — ₱{{ court.hourly_rate * 3 }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="bookCourt(court)"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="!store.currentPlayer || loading[court.id]">
|
||||
{{ loading[court.id] ? 'Booking...' : `Book for ₱${court.hourly_rate * (bookingForms[court.id]?.duration || 1)}` }}
|
||||
<!-- Status override -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button v-for="s in statuses" :key="s.val"
|
||||
@click="setStatus(court.id, s.val)"
|
||||
:class="court.status === s.val ? 'ring-2 ring-green-500 ' + s.cls : s.cls"
|
||||
class="btn-sm text-xs flex-1 rounded-lg py-1.5">
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TV Screen Link -->
|
||||
<router-link :to="`/screen/${court.id}`"
|
||||
class="mt-3 flex items-center justify-center gap-2 text-sm text-gray-400 hover:text-blue-400 transition-colors">
|
||||
<span>📺</span> Open TV Display
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Bookings -->
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-white mb-4">📅 Upcoming Bookings</h2>
|
||||
<div class="card p-0 overflow-hidden">
|
||||
<div v-if="upcomingBookings.length === 0" class="text-center py-8 text-gray-500">
|
||||
No upcoming bookings
|
||||
</div>
|
||||
<div v-for="booking in upcomingBookings" :key="booking.id"
|
||||
class="flex items-center justify-between px-4 py-3 border-b border-gray-800 last:border-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">📅</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white">{{ booking.player_name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ booking.court_name }}</div>
|
||||
<!-- Reservations -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-400">Reservations</h3>
|
||||
<button @click="openReserve(court)" class="text-xs text-green-400 hover:text-green-300">+ Reserve</button>
|
||||
</div>
|
||||
<div v-if="court.reservations && court.reservations.length === 0" class="text-xs text-gray-600 italic">No reservations</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="r in court.reservations" :key="r.id"
|
||||
class="bg-gray-800 rounded-lg p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-white">{{ r.group_name || r.player?.name || 'Unknown' }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatTime(r.start_time) }} – {{ formatTime(r.end_time) }}
|
||||
{{ formatDate(r.start_time) }}
|
||||
</p>
|
||||
<p v-if="r.notes" class="text-xs text-gray-600">{{ r.notes }}</p>
|
||||
</div>
|
||||
<button @click="deleteReservation(r.id)" class="text-xs text-red-500 hover:text-red-400 ml-3">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-white">{{ formatTime(booking.start_time) }}</div>
|
||||
<div class="text-xs text-gray-400">₱{{ booking.total_cost }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Toast -->
|
||||
<div v-if="successMsg" class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-3 rounded-xl shadow-lg z-50">
|
||||
✅ {{ successMsg }}
|
||||
<!-- Reserve Modal -->
|
||||
<div v-if="reserveModal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
@click.self="reserveModal = null">
|
||||
<div class="card p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Reserve {{ reserveModal.court.name }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Group / Player Name</label>
|
||||
<input v-model="reserveForm.group_name" class="input" placeholder="e.g., Lisa Cruz Group" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Start Time</label>
|
||||
<input v-model="reserveForm.start_time" type="datetime-local" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">End Time</label>
|
||||
<input v-model="reserveForm.end_time" type="datetime-local" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Notes</label>
|
||||
<input v-model="reserveForm.notes" class="input" placeholder="Optional notes" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button @click="reserveModal = null" class="btn-secondary flex-1">Cancel</button>
|
||||
<button @click="submitReservation" class="btn-primary flex-1">Reserve Court</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import axios from 'axios'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api, { courtStatusClass, formatTime, formatDate } from '../stores/api'
|
||||
|
||||
const store = useAppStore()
|
||||
const courts = ref([])
|
||||
const upcomingBookings = ref([])
|
||||
const loading = reactive({})
|
||||
const bookingForms = reactive({})
|
||||
const successMsg = ref('')
|
||||
const reserveModal = ref(null)
|
||||
const reserveForm = ref({})
|
||||
|
||||
const availableCourts = computed(() => courts.value.filter(c => c.current_status === 'available').length)
|
||||
const occupiedCourts = computed(() => courts.value.filter(c => c.current_status === 'occupied').length)
|
||||
const statuses = [
|
||||
{ val: 'available', label: '✓ Available', cls: 'bg-green-900/50 text-green-300 hover:bg-green-900' },
|
||||
{ val: 'in_use', label: '⚡ In Use', cls: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-900' },
|
||||
{ val: 'reserved', label: '📅 Reserved', cls: 'bg-blue-900/50 text-blue-300 hover:bg-blue-900' },
|
||||
{ val: 'maintenance', label: '🔧 Maint.', cls: 'bg-red-900/50 text-red-300 hover:bg-red-900' },
|
||||
]
|
||||
|
||||
const formatTime = (isoString) => {
|
||||
return new Date(isoString).toLocaleString('en-PH', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
})
|
||||
async function load() {
|
||||
const { data } = await api.get('/courts/')
|
||||
courts.value = data
|
||||
}
|
||||
|
||||
const bookCourt = async (court) => {
|
||||
if (!store.currentPlayer) return
|
||||
loading[court.id] = true
|
||||
try {
|
||||
const form = bookingForms[court.id]
|
||||
await store.bookCourt({
|
||||
player_id: store.currentPlayer.id,
|
||||
court_id: court.id,
|
||||
start_time: new Date(form.start_time).toISOString(),
|
||||
duration_hours: parseFloat(form.duration),
|
||||
})
|
||||
successMsg.value = `Court ${court.name} booked successfully!`
|
||||
setTimeout(() => successMsg.value = '', 3000)
|
||||
courts.value = await store.loadCourts()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || 'Booking failed')
|
||||
} finally {
|
||||
loading[court.id] = false
|
||||
async function setStatus(courtId, status) {
|
||||
await api.patch(`/courts/${courtId}/status`, { status })
|
||||
await load()
|
||||
}
|
||||
|
||||
function openReserve(court) {
|
||||
const now = new Date()
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
const fmt = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
reserveModal.value = { court }
|
||||
reserveForm.value = {
|
||||
group_name: '',
|
||||
start_time: fmt(now),
|
||||
end_time: fmt(new Date(now.getTime() + 2 * 3600000)),
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
courts.value = await store.loadCourts()
|
||||
|
||||
// Init booking forms
|
||||
courts.value.forEach(court => {
|
||||
const now = new Date()
|
||||
now.setMinutes(0, 0, 0)
|
||||
now.setHours(now.getHours() + 1)
|
||||
bookingForms[court.id] = {
|
||||
start_time: now.toISOString().slice(0, 16),
|
||||
duration: '1',
|
||||
}
|
||||
async function submitReservation() {
|
||||
if (!reserveModal.value) return
|
||||
await api.post(`/courts/${reserveModal.value.court.id}/reservations`, {
|
||||
group_name: reserveForm.value.group_name || null,
|
||||
start_time: new Date(reserveForm.value.start_time).toISOString(),
|
||||
end_time: new Date(reserveForm.value.end_time).toISOString(),
|
||||
notes: reserveForm.value.notes || null,
|
||||
})
|
||||
reserveModal.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.get('/api/courts/bookings/upcoming')
|
||||
upcomingBookings.value = res.data
|
||||
} catch (e) {}
|
||||
})
|
||||
async function deleteReservation(id) {
|
||||
await api.delete(`/courts/reservations/${id}`)
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -1,188 +1,222 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<!-- Hero Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="card text-center">
|
||||
<div class="text-3xl font-bold text-green-400">{{ courts.length }}</div>
|
||||
<div class="text-gray-400 text-sm mt-1">Total Courts</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ availableCourts }} available</div>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Live Court View</h1>
|
||||
<p class="text-gray-500 text-sm mt-0.5">Real-time status of all courts</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<div class="text-3xl font-bold text-blue-400">{{ activeMatches }}</div>
|
||||
<div class="text-gray-400 text-sm mt-1">Active Matches</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Live now</div>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<div class="text-3xl font-bold text-yellow-400">{{ lobbyMatches }}</div>
|
||||
<div class="text-gray-400 text-sm mt-1">Lobby Waiting</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Join now</div>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<div class="text-3xl font-bold text-purple-400">{{ players.length }}</div>
|
||||
<div class="text-gray-400 text-sm mt-1">Players</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Registered</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="load" class="btn-secondary btn-sm">↻ Refresh</button>
|
||||
<button v-if="activeEvent" @click="$router.push(`/events/${activeEvent.id}`)" class="btn-primary btn-sm">
|
||||
🎮 Run Event: {{ activeEvent.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<!-- Court Status Grid -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-white">Court Status</h2>
|
||||
<router-link to="/courts" class="text-green-400 text-sm hover:underline">View all →</router-link>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div v-for="court in courts" :key="court.id"
|
||||
class="card cursor-pointer hover:border-gray-600 transition-all"
|
||||
:class="court.current_status === 'occupied' ? 'border-red-800/50' : 'border-green-800/50'">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-bold text-white">{{ court.name }}</span>
|
||||
<span class="badge text-xs"
|
||||
:class="court.current_status === 'occupied' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'">
|
||||
{{ court.current_status === 'occupied' ? '🔴 Occupied' : '🟢 Available' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="court.current_match" class="space-y-2">
|
||||
<div class="flex items-center justify-between bg-gray-800 rounded-lg p-2">
|
||||
<span class="text-sm text-gray-300">{{ court.current_match.team1?.join(' & ') || 'Team 1' }}</span>
|
||||
<span class="text-xl font-bold text-white">{{ court.current_match.team1_score }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-gray-800 rounded-lg p-2">
|
||||
<span class="text-sm text-gray-300">{{ court.current_match.team2?.join(' & ') || 'Team 2' }}</span>
|
||||
<span class="text-xl font-bold text-white">{{ court.current_match.team2_score }}</span>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<span class="badge bg-blue-500/20 text-blue-400 text-xs capitalize">
|
||||
{{ court.current_match.stage?.replace('_', ' ') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-3">
|
||||
<router-link to="/courts" class="text-green-400 text-sm hover:underline">Book this court →</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Active event banner -->
|
||||
<div v-if="activeEvent" class="card p-4 mb-6 border-green-800 bg-green-950/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
|
||||
<span class="text-green-300 font-medium">{{ activeEvent.name }}</span>
|
||||
<span class="text-gray-500 text-sm">— {{ activeEvent.format }} · Stage: {{ activeEvent.current_stage || 'Setup' }}</span>
|
||||
</div>
|
||||
<router-link :to="`/events/${activeEvent.id}`" class="text-green-400 text-sm hover:underline">Manage →</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-white">🏆 Leaderboard</h2>
|
||||
<router-link to="/players" class="text-green-400 text-sm hover:underline">View all →</router-link>
|
||||
</div>
|
||||
<div class="card p-0 overflow-hidden">
|
||||
<div v-for="(player, idx) in leaderboard" :key="player.id"
|
||||
class="flex items-center gap-3 px-4 py-3 border-b border-gray-800 last:border-0 hover:bg-gray-800/50 transition-colors">
|
||||
<span class="text-lg font-bold w-6 text-center"
|
||||
:class="idx === 0 ? 'text-yellow-400' : idx === 1 ? 'text-gray-300' : idx === 2 ? 'text-amber-600' : 'text-gray-500'">
|
||||
{{ idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : idx + 1 }}
|
||||
<!-- Court Grid (2x2) -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div v-for="court in courts" :key="court.id" class="card p-5"
|
||||
:class="{
|
||||
'border-green-700': court.status === 'available',
|
||||
'border-yellow-600': court.status === 'in_use',
|
||||
'border-blue-700': court.status === 'reserved',
|
||||
'border-red-800': court.status === 'maintenance',
|
||||
}">
|
||||
<!-- Court header -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-white">{{ court.name }}</h2>
|
||||
<p class="text-xs text-gray-500">{{ court.court_type }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span :class="courtStatusClass(court.status)" class="capitalize">
|
||||
{{ court.status === 'in_use' ? 'In Use' : court.status }}
|
||||
</span>
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white flex-shrink-0"
|
||||
:style="{ backgroundColor: player.avatar_color }">
|
||||
{{ player.name[0] }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-white truncate">{{ player.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ player.wins }}W / {{ player.losses }}L</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-bold" :class="tierColorClass(player.membership_tier)">{{ Math.round(player.elo_rating) }}</div>
|
||||
<div class="text-xs capitalize" :class="tierColorClass(player.membership_tier)">{{ player.membership_tier }}</div>
|
||||
</div>
|
||||
<button @click="$router.push(`/screen/${court.id}`)"
|
||||
class="text-xs text-gray-600 hover:text-gray-400">📺 Screen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Tournament Summary -->
|
||||
<div v-if="tournament" class="card bg-gradient-to-br from-purple-900/30 to-gray-900 border-purple-800/50">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xl">🏆</span>
|
||||
<div>
|
||||
<div class="font-bold text-white text-sm">{{ tournament.tournament?.name }}</div>
|
||||
<div class="text-xs text-purple-400 capitalize">{{ tournament.tournament?.status?.replace('_', ' ') }}</div>
|
||||
<!-- Match in progress -->
|
||||
<div v-if="court.current_match" class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-500">Match #{{ court.current_match.id }}</span>
|
||||
<span class="text-xs text-yellow-400">⏱ {{ formatElapsed(court.current_match.elapsed_seconds) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-center flex-1">
|
||||
<p class="font-bold text-white text-sm">{{ court.current_match.player1_name || 'TBD' }}</p>
|
||||
</div>
|
||||
<div class="text-center px-4">
|
||||
<div class="text-2xl font-mono font-bold text-white">
|
||||
{{ court.current_match.score1 ?? '—' }}
|
||||
<span class="text-gray-600 text-lg">:</span>
|
||||
{{ court.current_match.score2 ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center flex-1">
|
||||
<p class="font-bold text-white text-sm">{{ court.current_match.player2_name || 'TBD' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-center">
|
||||
<div class="bg-purple-900/30 rounded-lg p-2">
|
||||
<div class="text-lg font-bold text-white">{{ tournament.winners_bracket?.length || 0 }}</div>
|
||||
<div class="text-xs text-gray-400">W Bracket</div>
|
||||
</div>
|
||||
<div class="bg-purple-900/30 rounded-lg p-2">
|
||||
<div class="text-lg font-bold text-white">{{ tournament.losers_bracket?.length || 0 }}</div>
|
||||
<div class="text-xs text-gray-400">L Bracket</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2 justify-center">
|
||||
<button @click="openScoreModal(court.current_match)" class="btn-primary btn-sm text-xs">
|
||||
Enter Score
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available / Reserved state -->
|
||||
<div v-else class="bg-gray-800/50 rounded-lg p-4 text-center">
|
||||
<div v-if="court.status === 'available'" class="text-green-500">
|
||||
<p class="text-3xl mb-1">✓</p>
|
||||
<p class="text-sm text-gray-400">Court Available</p>
|
||||
</div>
|
||||
<div v-else-if="court.status === 'reserved'">
|
||||
<p class="text-2xl mb-1">📅</p>
|
||||
<p class="text-sm text-gray-400">Reserved</p>
|
||||
<p v-if="court.next_reservation" class="text-xs text-blue-400 mt-1">
|
||||
{{ court.next_reservation.group_name }} · {{ formatTime(court.next_reservation.start_time) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="court.status === 'maintenance'">
|
||||
<p class="text-2xl mb-1">🔧</p>
|
||||
<p class="text-sm text-gray-400">Under Maintenance</p>
|
||||
</div>
|
||||
<router-link to="/tournament" class="btn btn-primary w-full text-center mt-3 block text-sm">
|
||||
View Bracket →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-bold text-white mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<router-link to="/matchmaking" class="flex flex-col items-center gap-2 bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 rounded-xl p-4 transition-all">
|
||||
<span class="text-3xl">⚔️</span>
|
||||
<span class="text-sm font-medium text-green-400">Find Match</span>
|
||||
</router-link>
|
||||
<router-link to="/courts" class="flex flex-col items-center gap-2 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl p-4 transition-all">
|
||||
<span class="text-3xl">📅</span>
|
||||
<span class="text-sm font-medium text-blue-400">Book Court</span>
|
||||
</router-link>
|
||||
<router-link to="/tournament" class="flex flex-col items-center gap-2 bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 rounded-xl p-4 transition-all">
|
||||
<span class="text-3xl">🏆</span>
|
||||
<span class="text-sm font-medium text-purple-400">Tournament</span>
|
||||
</router-link>
|
||||
<router-link to="/screen/1" class="flex flex-col items-center gap-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 transition-all">
|
||||
<span class="text-3xl">📺</span>
|
||||
<span class="text-sm font-medium text-yellow-400">Live Screen</span>
|
||||
</router-link>
|
||||
<!-- Bottom split: Upcoming + Bracket -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<!-- Upcoming matches -->
|
||||
<div class="col-span-2 card p-4">
|
||||
<h3 class="font-semibold text-gray-300 mb-3">📋 Upcoming Matches</h3>
|
||||
<div v-if="upcoming.length === 0" class="text-gray-600 text-sm py-4 text-center">No upcoming matches</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="m in upcoming" :key="m.id"
|
||||
class="flex items-center justify-between p-3 bg-gray-800 rounded-lg text-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-gray-500 text-xs">R{{ m.round_number }}</span>
|
||||
<span class="text-white font-medium">{{ m.player1_name }}</span>
|
||||
<span class="text-gray-600">vs</span>
|
||||
<span class="text-white font-medium">{{ m.player2_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>{{ m.stage_name }}</span>
|
||||
<button @click="startMatch(m.id)" class="btn-primary btn-sm text-xs px-2 py-1">▶ Start</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick stats -->
|
||||
<div class="card p-4">
|
||||
<h3 class="font-semibold text-gray-300 mb-3">📊 Quick Stats</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-gray-800 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-500">Courts In Use</p>
|
||||
<p class="text-2xl font-bold text-yellow-400">{{ courts.filter(c => c.status === 'in_use').length }}</p>
|
||||
<p class="text-xs text-gray-600">of {{ courts.length }} total</p>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-500">Live Matches</p>
|
||||
<p class="text-2xl font-bold text-green-400">{{ courts.filter(c => c.current_match).length }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-500">Queued Matches</p>
|
||||
<p class="text-2xl font-bold text-blue-400">{{ upcoming.length }}</p>
|
||||
</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-1">{{ 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-1">{{ 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</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import api, { courtStatusClass, formatElapsed, formatTime } from '../stores/api'
|
||||
|
||||
const store = useAppStore()
|
||||
const courts = ref([])
|
||||
const leaderboard = ref([])
|
||||
const lobbyData = ref({ lobby: [], active: [] })
|
||||
const tournament = ref(null)
|
||||
const players = ref([])
|
||||
const upcoming = ref([])
|
||||
const activeEvent = ref(null)
|
||||
const scoreModal = ref(null)
|
||||
const scoreInput1 = ref(0)
|
||||
const scoreInput2 = ref(0)
|
||||
let timer = null
|
||||
|
||||
const availableCourts = computed(() => courts.value.filter(c => c.current_status === 'available').length)
|
||||
const activeMatches = computed(() => lobbyData.value.active?.length || 0)
|
||||
const lobbyMatches = computed(() => lobbyData.value.lobby?.length || 0)
|
||||
|
||||
const tierColorClass = (tier) => {
|
||||
const classes = {
|
||||
bronze: 'text-amber-600',
|
||||
silver: 'text-gray-400',
|
||||
gold: 'text-yellow-400',
|
||||
platinum: 'text-cyan-400',
|
||||
elite: 'text-purple-400',
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await api.get('/dashboard/')
|
||||
courts.value = data.courts
|
||||
upcoming.value = data.upcoming_matches
|
||||
activeEvent.value = data.active_event
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
return classes[tier] || 'text-gray-400'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
courts.value = await store.loadCourts()
|
||||
leaderboard.value = await store.getLeaderboard(8)
|
||||
players.value = await store.loadPlayers()
|
||||
async function startMatch(id) {
|
||||
await api.post(`/matches/${id}/start`)
|
||||
await load()
|
||||
}
|
||||
|
||||
try {
|
||||
const [lobby, active] = await Promise.all([store.loadLobby(), store.loadActiveMatches()])
|
||||
lobbyData.value = { lobby, active }
|
||||
} catch (e) {}
|
||||
function openScoreModal(match) {
|
||||
scoreModal.value = match
|
||||
scoreInput1.value = match.score1 ?? 0
|
||||
scoreInput2.value = match.score2 ?? 0
|
||||
}
|
||||
|
||||
try {
|
||||
const tournaments = await store.loadTournaments()
|
||||
const active = tournaments.find(t => t.status === 'in_progress')
|
||||
if (active) tournament.value = await store.loadTournament(active.id)
|
||||
} catch (e) {}
|
||||
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()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
timer = setInterval(load, 5000)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
278
frontend/src/views/EventBuilder.vue
Normal file
278
frontend/src/views/EventBuilder.vue
Normal 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>
|
||||
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>
|
||||
56
frontend/src/views/Events.vue
Normal file
56
frontend/src/views/Events.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Events</h1>
|
||||
<p class="text-gray-500 text-sm">{{ events.length }} events</p>
|
||||
</div>
|
||||
<router-link to="/events/new" class="btn-primary">+ New Event</router-link>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="e in events" :key="e.id" class="card p-4 hover:border-gray-700 transition-colors cursor-pointer"
|
||||
@click="$router.push(`/events/${e.id}`)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center text-xl">
|
||||
{{ e.format === 'Doubles' ? '👫' : '🏓' }}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-white">{{ e.name }}</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ e.format }} · {{ e.player_count }} players · {{ e.stage_count }} stages
|
||||
· {{ e.courts_count }} courts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span :class="eventStatusClass(e.status)" class="capitalize">{{ e.status }}</span>
|
||||
<span class="text-gray-600 text-xs">{{ formatDate(e.created_at) }}</span>
|
||||
<span class="text-gray-500">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="events.length === 0" class="text-center py-16">
|
||||
<p class="text-5xl mb-4">🏆</p>
|
||||
<p class="text-gray-500 mb-4">No events yet</p>
|
||||
<router-link to="/events/new" class="btn-primary">Create Your First Event</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api, { eventStatusClass, formatDate } from '../stores/api'
|
||||
|
||||
const events = ref([])
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/events/')
|
||||
events.value = data
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -1,408 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-white">⚔️ Matchmaking</h1>
|
||||
<button @click="refreshData" class="btn btn-secondary text-sm">🔄 Refresh</button>
|
||||
</div>
|
||||
|
||||
<!-- Stage Tabs -->
|
||||
<div class="flex gap-2 bg-gray-900 p-1 rounded-xl w-fit">
|
||||
<button v-for="stage in stages" :key="stage.id"
|
||||
@click="activeStage = stage.id"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-all"
|
||||
:class="activeStage === stage.id ? 'bg-green-500 text-white' : 'text-gray-400 hover:text-white'">
|
||||
{{ stage.icon }} {{ stage.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stage Info -->
|
||||
<div class="card" :class="stageCardClass">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="text-4xl">{{ currentStage.icon }}</span>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-white">{{ currentStage.label }}</h2>
|
||||
<p class="text-gray-400 text-sm mt-1">{{ currentStage.description }}</p>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<span v-for="rule in currentStage.rules" :key="rule" class="badge bg-gray-800 text-gray-300 text-xs">
|
||||
{{ rule }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<!-- Create Match -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="card space-y-4">
|
||||
<h2 class="font-bold text-white">Create Match</h2>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Match Title</label>
|
||||
<input v-model="createForm.title" type="text" placeholder="e.g. Friday Night Doubles"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Match Type</label>
|
||||
<select v-model="createForm.match_type"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500">
|
||||
<option value="doubles">Doubles (4 players)</option>
|
||||
<option value="singles">Singles (2 players)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="activeStage === 'skill_based'">
|
||||
<label class="text-xs text-gray-400">ELO Tolerance (±)</label>
|
||||
<select v-model="createForm.elo_tolerance"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500">
|
||||
<option value="100">±100 (Strict)</option>
|
||||
<option value="200">±200 (Standard)</option>
|
||||
<option value="300">±300 (Relaxed)</option>
|
||||
</select>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
Your ELO: {{ store.currentPlayer?.elo_rating?.toFixed(0) }} →
|
||||
Range: {{ eloRange.min }}-{{ eloRange.max }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="createMatch" :disabled="!createForm.title || !store.currentPlayer || creatingMatch"
|
||||
class="btn btn-primary w-full">
|
||||
{{ creatingMatch ? 'Creating...' : '+ Create Match' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- My Active Matches -->
|
||||
<div v-if="myMatches.length > 0" class="card mt-4">
|
||||
<h3 class="font-bold text-white mb-3">My Matches</h3>
|
||||
<div v-for="m in myMatches" :key="m.id" class="bg-gray-800 rounded-lg p-3 mb-2">
|
||||
<div class="text-sm font-medium text-white">{{ m.title }}</div>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<span class="badge text-xs capitalize"
|
||||
:class="m.status === 'in_progress' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'">
|
||||
{{ m.status.replace('_', ' ') }}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button v-if="m.status === 'in_progress'"
|
||||
@click="openScoreModal(m)"
|
||||
class="text-xs bg-blue-600 hover:bg-blue-500 text-white px-2 py-1 rounded">
|
||||
Score
|
||||
</button>
|
||||
<button v-if="m.status === 'lobby' && m.current_players >= m.max_players"
|
||||
@click="startMatch(m.id)"
|
||||
class="text-xs bg-green-600 hover:bg-green-500 text-white px-2 py-1 rounded">
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lobby -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-bold text-white">Open Lobbies ({{ filteredLobby.length }})</h2>
|
||||
<div class="text-sm text-gray-400">Auto-refresh every 5s</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredLobby.length === 0" class="card text-center py-12">
|
||||
<div class="text-4xl mb-3">🏓</div>
|
||||
<div class="text-gray-400">No matches in lobby</div>
|
||||
<div class="text-gray-500 text-sm mt-1">Create one above!</div>
|
||||
</div>
|
||||
|
||||
<div v-for="match in filteredLobby" :key="match.id"
|
||||
class="card hover:border-gray-600 transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div class="font-bold text-white">{{ match.title }}</div>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<span class="badge bg-gray-800 text-gray-300 text-xs capitalize">
|
||||
{{ match.stage.replace('_', ' ') }}
|
||||
</span>
|
||||
<span class="badge bg-gray-800 text-gray-300 text-xs">
|
||||
{{ match.match_type }}
|
||||
</span>
|
||||
<span v-if="match.min_elo" class="badge bg-orange-500/20 text-orange-400 text-xs">
|
||||
ELO {{ match.min_elo?.toFixed(0) }}-{{ match.max_elo?.toFixed(0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-medium text-white">{{ match.current_players }}/{{ match.max_players }}</div>
|
||||
<div class="text-xs text-gray-400">players</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teams -->
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<div class="bg-blue-900/20 border border-blue-800/30 rounded-lg p-2">
|
||||
<div class="text-xs text-blue-400 mb-1">Team 1</div>
|
||||
<div v-for="p in match.team1" :key="p.id" class="flex items-center gap-1 text-sm text-white">
|
||||
<div class="w-5 h-5 rounded-full text-xs flex items-center justify-center font-bold"
|
||||
:style="{ backgroundColor: p.avatar_color }">{{ p.name[0] }}</div>
|
||||
{{ p.name }}
|
||||
<span class="text-xs text-gray-400">({{ p.elo.toFixed(0) }})</span>
|
||||
</div>
|
||||
<div v-for="i in Math.max(0, (match.max_players/2) - match.team1.length)" :key="`t1-${i}`"
|
||||
class="text-xs text-gray-600 italic">Empty slot...</div>
|
||||
</div>
|
||||
<div class="bg-red-900/20 border border-red-800/30 rounded-lg p-2">
|
||||
<div class="text-xs text-red-400 mb-1">Team 2</div>
|
||||
<div v-for="p in match.team2" :key="p.id" class="flex items-center gap-1 text-sm text-white">
|
||||
<div class="w-5 h-5 rounded-full text-xs flex items-center justify-center font-bold"
|
||||
:style="{ backgroundColor: p.avatar_color }">{{ p.name[0] }}</div>
|
||||
{{ p.name }}
|
||||
<span class="text-xs text-gray-400">({{ p.elo.toFixed(0) }})</span>
|
||||
</div>
|
||||
<div v-for="i in Math.max(0, (match.max_players/2) - match.team2.length)" :key="`t2-${i}`"
|
||||
class="text-xs text-gray-600 italic">Empty slot...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
<button v-if="!isInMatch(match)"
|
||||
@click="joinMatch(match.id, 1)"
|
||||
class="btn btn-primary text-sm flex-1"
|
||||
:disabled="match.team1.length >= match.max_players / 2">
|
||||
Join Team 1
|
||||
</button>
|
||||
<button v-if="!isInMatch(match)"
|
||||
@click="joinMatch(match.id, 2)"
|
||||
class="btn btn-secondary text-sm flex-1"
|
||||
:disabled="match.team2.length >= match.max_players / 2">
|
||||
Join Team 2
|
||||
</button>
|
||||
<button v-if="match.current_players >= match.max_players && isCreator(match)"
|
||||
@click="startMatch(match.id)"
|
||||
class="btn bg-yellow-500 hover:bg-yellow-400 text-black text-sm flex-1 font-bold">
|
||||
🚀 Start Match
|
||||
</button>
|
||||
<span v-if="isInMatch(match)" class="badge bg-green-500/20 text-green-400">✓ Joined</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Matches Section -->
|
||||
<div v-if="activeMatches.length > 0">
|
||||
<h2 class="font-bold text-white mb-3">🔥 Active Matches</h2>
|
||||
<div v-for="match in activeMatches" :key="match.id"
|
||||
class="card border-green-800/30">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="font-bold text-white">{{ match.title }}</div>
|
||||
<div class="flex gap-2">
|
||||
<span class="badge bg-green-500/20 text-green-400">🔴 Live</span>
|
||||
<span v-if="match.court_name" class="badge bg-gray-700 text-gray-300 text-xs">{{ match.court_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-6 py-4">
|
||||
<div class="text-center">
|
||||
<div class="text-sm text-gray-400 mb-1">{{ match.team1.map(p => p.name).join(' & ') }}</div>
|
||||
<div class="text-5xl font-black text-white">{{ match.team1_score }}</div>
|
||||
</div>
|
||||
<div class="text-2xl text-gray-500">vs</div>
|
||||
<div class="text-center">
|
||||
<div class="text-sm text-gray-400 mb-1">{{ match.team2.map(p => p.name).join(' & ') }}</div>
|
||||
<div class="text-5xl font-black text-white">{{ match.team2_score }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-center">
|
||||
<button @click="openScoreModal(match)" class="btn btn-secondary text-sm">📊 Update Score</button>
|
||||
<button @click="openCompleteModal(match)" class="btn btn-danger text-sm">🏁 End Match</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Update Modal -->
|
||||
<div v-if="scoreModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ completeMode ? '🏁 End Match' : '📊 Update Score' }}</h3>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="text-center">
|
||||
<div class="text-sm text-gray-400 mb-2">{{ scoreModal.team1.map(p => p.name).join(' & ') }}</div>
|
||||
<input type="number" v-model="scoreForm.team1_score" min="0" max="21"
|
||||
class="w-full text-center text-3xl font-bold bg-gray-800 border border-gray-700 text-white rounded-xl py-3 focus:outline-none focus:border-green-500" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-sm text-gray-400 mb-2">{{ scoreModal.team2.map(p => p.name).join(' & ') }}</div>
|
||||
<input type="number" v-model="scoreForm.team2_score" min="0" max="21"
|
||||
class="w-full text-center text-3xl font-bold bg-gray-800 border border-gray-700 text-white rounded-xl py-3 focus:outline-none focus:border-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="scoreModal = null" class="btn btn-secondary flex-1">Cancel</button>
|
||||
<button @click="submitScore" :class="completeMode ? 'btn btn-danger' : 'btn btn-primary'" class="flex-1">
|
||||
{{ completeMode ? 'End & Update ELO' : 'Update Score' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div v-if="toast" class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-3 rounded-xl shadow-lg z-50">
|
||||
✅ {{ toast }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const store = useAppStore()
|
||||
const lobby = ref([])
|
||||
const activeMatches = ref([])
|
||||
const activeStage = ref('open')
|
||||
const creatingMatch = ref(false)
|
||||
const scoreModal = ref(null)
|
||||
const completeMode = ref(false)
|
||||
const toast = ref('')
|
||||
const scoreForm = reactive({ team1_score: 0, team2_score: 0 })
|
||||
let refreshInterval = null
|
||||
|
||||
const stages = [
|
||||
{
|
||||
id: 'open',
|
||||
icon: '🎯',
|
||||
label: 'Stage 1: Open Match',
|
||||
description: 'Anyone can join. No skill restrictions. Great for casual play and meeting new players.',
|
||||
rules: ['No ELO requirement', 'Free to join', 'Performance tracked silently'],
|
||||
},
|
||||
{
|
||||
id: 'skill_based',
|
||||
icon: '⚡',
|
||||
label: 'Stage 2: Skill-Based',
|
||||
description: 'ELO-gated matchmaking. Players match within ±200 ELO points for balanced competition.',
|
||||
rules: ['ELO within ±200', 'Team avg must be in range', 'Affects ELO rating'],
|
||||
},
|
||||
]
|
||||
|
||||
const createForm = reactive({ title: '', match_type: 'doubles', elo_tolerance: 200 })
|
||||
|
||||
const currentStage = computed(() => stages.find(s => s.id === activeStage.value))
|
||||
|
||||
const stageCardClass = computed(() => ({
|
||||
'border-blue-800/50 bg-blue-900/10': activeStage.value === 'open',
|
||||
'border-orange-800/50 bg-orange-900/10': activeStage.value === 'skill_based',
|
||||
}))
|
||||
|
||||
const eloRange = computed(() => {
|
||||
const elo = store.currentPlayer?.elo_rating || 1000
|
||||
const t = parseFloat(createForm.elo_tolerance) || 200
|
||||
return { min: Math.max(0, Math.round(elo - t)), max: Math.round(elo + t) }
|
||||
})
|
||||
|
||||
const filteredLobby = computed(() => {
|
||||
return lobby.value.filter(m => m.stage === activeStage.value)
|
||||
})
|
||||
|
||||
const isInMatch = (match) => {
|
||||
const pid = store.currentPlayer?.id
|
||||
return [...match.team1, ...match.team2].some(p => p.id === pid)
|
||||
}
|
||||
|
||||
const isCreator = (match) => {
|
||||
const pid = store.currentPlayer?.id
|
||||
return match.team1[0]?.id === pid || match.team2[0]?.id === pid
|
||||
}
|
||||
|
||||
const myMatches = computed(() => {
|
||||
const pid = store.currentPlayer?.id
|
||||
if (!pid) return []
|
||||
return activeMatches.value.filter(m => [...m.team1, ...m.team2].some(p => p.id === pid))
|
||||
})
|
||||
|
||||
const createMatch = async () => {
|
||||
if (!createForm.title || !store.currentPlayer) return
|
||||
creatingMatch.value = true
|
||||
try {
|
||||
await store.createMatch({
|
||||
title: createForm.title,
|
||||
stage: activeStage.value,
|
||||
match_type: createForm.match_type,
|
||||
creator_player_id: store.currentPlayer.id,
|
||||
elo_tolerance: parseFloat(createForm.elo_tolerance),
|
||||
})
|
||||
createForm.title = ''
|
||||
showToast('Match created! Players can now join.')
|
||||
await refreshData()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || 'Error creating match')
|
||||
} finally {
|
||||
creatingMatch.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const joinMatch = async (matchId, team) => {
|
||||
if (!store.currentPlayer) return
|
||||
try {
|
||||
await store.joinMatch(matchId, store.currentPlayer.id, team)
|
||||
showToast('Joined match!')
|
||||
await refreshData()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || 'Cannot join match')
|
||||
}
|
||||
}
|
||||
|
||||
const startMatch = async (matchId) => {
|
||||
try {
|
||||
await store.startMatch(matchId)
|
||||
showToast('Match started! Court auto-assigned.')
|
||||
await refreshData()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || 'Cannot start match')
|
||||
}
|
||||
}
|
||||
|
||||
const openScoreModal = (match) => {
|
||||
scoreModal.value = match
|
||||
completeMode.value = false
|
||||
scoreForm.team1_score = match.team1_score || 0
|
||||
scoreForm.team2_score = match.team2_score || 0
|
||||
}
|
||||
|
||||
const openCompleteModal = (match) => {
|
||||
scoreModal.value = match
|
||||
completeMode.value = true
|
||||
scoreForm.team1_score = match.team1_score || 0
|
||||
scoreForm.team2_score = match.team2_score || 0
|
||||
}
|
||||
|
||||
const submitScore = async () => {
|
||||
if (!scoreModal.value) return
|
||||
try {
|
||||
if (completeMode.value) {
|
||||
await store.completeMatch(scoreModal.value.id, scoreForm.team1_score, scoreForm.team2_score)
|
||||
showToast('Match completed! ELO updated.')
|
||||
} else {
|
||||
await store.updateScore(scoreModal.value.id, scoreForm.team1_score, scoreForm.team2_score)
|
||||
showToast('Score updated!')
|
||||
}
|
||||
scoreModal.value = null
|
||||
await refreshData()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || 'Error')
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (msg) => {
|
||||
toast.value = msg
|
||||
setTimeout(() => toast.value = '', 3000)
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
[lobby.value, activeMatches.value] = await Promise.all([store.loadLobby(), store.loadActiveMatches()])
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshData()
|
||||
refreshInterval = setInterval(refreshData, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) clearInterval(refreshInterval)
|
||||
})
|
||||
</script>
|
||||
@@ -1,118 +1,103 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-white">👥 Players</h1>
|
||||
<button @click="showRegister = true" class="btn btn-primary text-sm">+ Register Player</button>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Player Management</h1>
|
||||
<p class="text-gray-500 text-sm">{{ players.length }} players registered</p>
|
||||
</div>
|
||||
<button @click="openAdd" class="btn-primary">+ Add Player</button>
|
||||
</div>
|
||||
|
||||
<!-- Tier Distribution -->
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<div v-for="tier in tierStats" :key="tier.name"
|
||||
class="flex items-center gap-2 bg-gray-900 border border-gray-800 rounded-xl px-4 py-2">
|
||||
<span class="text-lg">{{ tier.icon }}</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium" :class="tier.color">{{ tier.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ tier.count }} players</div>
|
||||
<!-- Player Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-800">
|
||||
<tr class="text-gray-500 text-xs uppercase tracking-wider">
|
||||
<th class="text-left px-4 py-3">Player</th>
|
||||
<th class="text-left px-4 py-3">Contact</th>
|
||||
<th class="text-center px-4 py-3">Skill</th>
|
||||
<th class="text-center px-4 py-3">ELO</th>
|
||||
<th class="text-center px-4 py-3">W / L</th>
|
||||
<th class="text-center px-4 py-3">Played</th>
|
||||
<th class="text-right px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800/50">
|
||||
<tr v-for="(p, i) in players" :key="p.id" class="hover:bg-gray-800/30 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-sm font-bold text-gray-300">
|
||||
{{ i + 1 }}
|
||||
</div>
|
||||
<span class="font-medium text-white">{{ p.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-400">{{ p.contact || '—' }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span :class="skillBadgeClass(p.skill_level)">{{ p.skill_level }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center font-mono font-bold text-white">{{ Math.round(p.elo) }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="text-green-400">{{ p.wins }}</span>
|
||||
<span class="text-gray-600 mx-1">/</span>
|
||||
<span class="text-red-400">{{ p.losses }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-gray-400">{{ p.matches_played }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button @click="openEdit(p)" class="text-xs text-blue-400 hover:text-blue-300 mr-3">Edit</button>
|
||||
<button @click="confirmDelete(p)" class="text-xs text-red-500 hover:text-red-400">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="players.length === 0" class="text-center text-gray-600 py-12">
|
||||
No players yet. Add your first player!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="modal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
@click.self="modal = null">
|
||||
<div class="card p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ modal.id ? 'Edit' : 'Add' }} Player</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Name *</label>
|
||||
<input v-model="modal.name" class="input" placeholder="Full name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Contact</label>
|
||||
<input v-model="modal.contact" class="input" placeholder="Phone or email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Skill Level *</label>
|
||||
<select v-model="modal.skill_level" class="input">
|
||||
<option value="Beginner">Beginner</option>
|
||||
<option value="Intermediate">Intermediate</option>
|
||||
<option value="Advanced">Advanced</option>
|
||||
<option value="Elite">Elite</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button @click="modal = null" class="btn-secondary flex-1">Cancel</button>
|
||||
<button @click="savePlayer" :disabled="!modal.name" class="btn-primary flex-1">
|
||||
{{ modal.id ? 'Save Changes' : 'Add Player' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Players Table -->
|
||||
<div class="card p-0 overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-800 text-left">
|
||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Rank</th>
|
||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Player</th>
|
||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">ELO</th>
|
||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Tier</th>
|
||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">W/L</th>
|
||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Win Rate</th>
|
||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Matches</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(player, idx) in players" :key="player.id"
|
||||
class="border-b border-gray-800/50 last:border-0 hover:bg-gray-800/30 transition-colors cursor-pointer"
|
||||
:class="player.id === store.currentPlayer?.id ? 'bg-green-900/10' : ''">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span :class="idx === 0 ? 'text-yellow-400 text-lg' : idx === 1 ? 'text-gray-300 text-lg' : idx === 2 ? 'text-amber-600 text-lg' : 'text-gray-500'">
|
||||
{{ idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : `#${idx + 1}` }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: player.avatar_color }">
|
||||
{{ player.name[0] }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white flex items-center gap-1">
|
||||
{{ player.name }}
|
||||
<span v-if="player.id === store.currentPlayer?.id" class="badge bg-green-500/20 text-green-400 text-xs">You</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">{{ player.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-white font-mono font-bold">{{ Math.round(player.elo_rating) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="badge capitalize text-xs px-2 py-1"
|
||||
:class="tierBadge(player.membership_tier)">
|
||||
{{ player.membership_tier }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-300">
|
||||
<span class="text-green-400">{{ player.wins }}W</span>
|
||||
<span class="text-gray-600 mx-1">/</span>
|
||||
<span class="text-red-400">{{ player.losses }}L</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-gray-800 rounded-full h-1.5 w-16">
|
||||
<div class="h-full rounded-full bg-green-500"
|
||||
:style="{ width: player.win_rate + '%' }"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{ player.win_rate }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-400">{{ player.total_matches }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Register Modal -->
|
||||
<div v-if="showRegister" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-bold text-white mb-4">👤 Register New Player</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Full Name *</label>
|
||||
<input v-model="registerForm.name" type="text" placeholder="Juan Dela Cruz"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Email *</label>
|
||||
<input v-model="registerForm.email" type="email" placeholder="juan@email.com"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Phone</label>
|
||||
<input v-model="registerForm.phone" type="tel" placeholder="+63 912 345 6789"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-3 text-xs text-gray-400">
|
||||
New players start at <span class="text-white font-bold">1000 ELO</span> (Bronze tier).
|
||||
Rating updates automatically after each match.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button @click="showRegister = false" class="btn btn-secondary flex-1">Cancel</button>
|
||||
<button @click="registerPlayer" :disabled="!registerForm.name || !registerForm.email"
|
||||
class="btn btn-primary flex-1">Register</button>
|
||||
<!-- Delete Confirm -->
|
||||
<div v-if="deleteTarget" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
@click.self="deleteTarget = null">
|
||||
<div class="card p-6 w-full max-w-sm text-center">
|
||||
<p class="text-xl mb-2">⚠️</p>
|
||||
<h3 class="text-lg font-bold text-white mb-2">Delete Player?</h3>
|
||||
<p class="text-gray-400 text-sm mb-6">Remove <strong>{{ deleteTarget.name }}</strong> from the system?</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="deleteTarget = null" class="btn-secondary flex-1">Cancel</button>
|
||||
<button @click="doDelete" class="btn-danger flex-1">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,52 +105,55 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import axios from 'axios'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api, { skillBadgeClass } from '../stores/api'
|
||||
|
||||
const store = useAppStore()
|
||||
const players = ref([])
|
||||
const showRegister = ref(false)
|
||||
const registerForm = reactive({ name: '', email: '', phone: '' })
|
||||
const modal = ref(null)
|
||||
const deleteTarget = ref(null)
|
||||
|
||||
const tierStats = computed(() => {
|
||||
const tiers = { bronze: 0, silver: 0, gold: 0, platinum: 0, elite: 0 }
|
||||
players.value.forEach(p => { tiers[p.membership_tier] = (tiers[p.membership_tier] || 0) + 1 })
|
||||
return [
|
||||
{ name: 'Bronze', icon: '🥉', color: 'text-amber-600', count: tiers.bronze },
|
||||
{ name: 'Silver', icon: '🥈', color: 'text-gray-400', count: tiers.silver },
|
||||
{ name: 'Gold', icon: '🥇', color: 'text-yellow-400', count: tiers.gold },
|
||||
{ name: 'Platinum', icon: '💎', color: 'text-cyan-400', count: tiers.platinum },
|
||||
{ name: 'Elite', icon: '👑', color: 'text-purple-400', count: tiers.elite },
|
||||
]
|
||||
})
|
||||
|
||||
const tierBadge = (tier) => {
|
||||
const classes = {
|
||||
bronze: 'bg-amber-900/40 text-amber-500 border border-amber-800/50',
|
||||
silver: 'bg-gray-700/50 text-gray-300 border border-gray-600/50',
|
||||
gold: 'bg-yellow-900/40 text-yellow-400 border border-yellow-800/50',
|
||||
platinum: 'bg-cyan-900/40 text-cyan-400 border border-cyan-800/50',
|
||||
elite: 'bg-purple-900/40 text-purple-400 border border-purple-800/50',
|
||||
}
|
||||
return classes[tier] || 'bg-gray-800 text-gray-400'
|
||||
async function load() {
|
||||
const { data } = await api.get('/players/')
|
||||
players.value = data
|
||||
}
|
||||
|
||||
const registerPlayer = async () => {
|
||||
try {
|
||||
const res = await axios.post('/api/players/', registerForm)
|
||||
players.value = await store.loadPlayers()
|
||||
showRegister.value = false
|
||||
registerForm.name = ''
|
||||
registerForm.email = ''
|
||||
registerForm.phone = ''
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || 'Registration failed')
|
||||
}
|
||||
function openAdd() {
|
||||
modal.value = { name: '', contact: '', skill_level: 'Intermediate' }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
players.value = await store.loadPlayers()
|
||||
})
|
||||
function openEdit(p) {
|
||||
modal.value = { ...p }
|
||||
}
|
||||
|
||||
async function savePlayer() {
|
||||
if (!modal.value) return
|
||||
if (modal.value.id) {
|
||||
await api.put(`/players/${modal.value.id}`, {
|
||||
name: modal.value.name,
|
||||
contact: modal.value.contact,
|
||||
skill_level: modal.value.skill_level,
|
||||
})
|
||||
} else {
|
||||
await api.post('/players/', {
|
||||
name: modal.value.name,
|
||||
contact: modal.value.contact,
|
||||
skill_level: modal.value.skill_level,
|
||||
})
|
||||
}
|
||||
modal.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
function confirmDelete(p) {
|
||||
deleteTarget.value = p
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
await api.delete(`/players/${deleteTarget.value.id}`)
|
||||
deleteTarget.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
109
frontend/src/views/Screen.vue
Normal file
109
frontend/src/views/Screen.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-950 flex flex-col items-center justify-center p-8">
|
||||
<!-- Header -->
|
||||
<div class="absolute top-6 left-6 flex items-center gap-2">
|
||||
<span class="text-3xl">🏓</span>
|
||||
<span class="text-xl font-bold text-gray-500">ServeSync</span>
|
||||
</div>
|
||||
<div class="absolute top-6 right-6 text-gray-600 text-2xl font-mono">{{ timeStr }}</div>
|
||||
|
||||
<div v-if="data" class="w-full max-w-4xl">
|
||||
<!-- Court name -->
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-6xl font-black text-white mb-2">{{ data.court.name }}</h1>
|
||||
<p class="text-2xl" :class="{
|
||||
'text-green-400': data.court.status === 'available',
|
||||
'text-yellow-400': data.court.status === 'in_use',
|
||||
'text-blue-400': data.court.status === 'reserved',
|
||||
'text-red-400': data.court.status === 'maintenance',
|
||||
}">
|
||||
{{ statusLabel(data.court.status) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Match in progress -->
|
||||
<div v-if="data.current_match" class="text-center">
|
||||
<!-- Players -->
|
||||
<div class="flex items-center justify-center gap-12 mb-8">
|
||||
<div class="flex-1 text-right">
|
||||
<p class="text-5xl font-black text-white">{{ data.current_match.player1_name }}</p>
|
||||
</div>
|
||||
<div class="text-gray-600 text-4xl font-bold">VS</div>
|
||||
<div class="flex-1 text-left">
|
||||
<p class="text-5xl font-black text-white">{{ data.current_match.player2_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score -->
|
||||
<div class="flex items-center justify-center gap-8 mb-8">
|
||||
<div class="text-9xl font-mono font-black" :class="data.current_match.score1 > data.current_match.score2 ? 'text-green-400' : 'text-white'">
|
||||
{{ data.current_match.score1 ?? 0 }}
|
||||
</div>
|
||||
<div class="text-6xl text-gray-700 font-bold">:</div>
|
||||
<div class="text-9xl font-mono font-black" :class="data.current_match.score2 > data.current_match.score1 ? 'text-green-400' : 'text-white'">
|
||||
{{ data.current_match.score2 ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer -->
|
||||
<div class="text-3xl font-mono text-gray-500">
|
||||
⏱ {{ formatElapsed(data.current_match.elapsed_seconds) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No match -->
|
||||
<div v-else class="text-center">
|
||||
<p class="text-8xl mb-6">{{ statusEmoji(data.court.status) }}</p>
|
||||
<p class="text-3xl text-gray-600">{{ statusLabel(data.court.status) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Court selector -->
|
||||
<div class="absolute bottom-6 flex gap-4">
|
||||
<router-link v-for="i in 4" :key="i" :to="`/screen/${i}`"
|
||||
class="px-4 py-2 rounded-lg text-sm transition-colors"
|
||||
:class="Number(route.params.id) === i ? 'bg-green-900 text-green-300' : 'text-gray-700 hover:text-gray-500'">
|
||||
Court {{ String.fromCharCode(64 + i) }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api, { formatElapsed } from '../stores/api'
|
||||
|
||||
const route = useRoute()
|
||||
const data = ref(null)
|
||||
const timeStr = ref('')
|
||||
let timer = null
|
||||
let clockTimer = null
|
||||
|
||||
function statusLabel(s) {
|
||||
return { available: 'Court Available', in_use: 'Match in Progress', reserved: 'Reserved', maintenance: 'Under Maintenance' }[s] || s
|
||||
}
|
||||
function statusEmoji(s) {
|
||||
return { available: '✓', reserved: '📅', maintenance: '🔧' }[s] || '🏓'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { data: d } = await api.get(`/dashboard/screen/${route.params.id}`)
|
||||
data.value = d
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
timer = setInterval(load, 3000)
|
||||
const updateClock = () => {
|
||||
timeStr.value = new Date().toLocaleTimeString('en-PH', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
updateClock()
|
||||
clockTimer = setInterval(updateClock, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
clearInterval(clockTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -1,386 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-950 overflow-hidden" :class="{ 'cursor-none': isFullscreen }">
|
||||
<!-- Screen Mode Selector (top bar, hidden when watching) -->
|
||||
<div v-if="!isFullscreen" class="bg-gray-900 border-b border-gray-800 p-3 flex items-center gap-4">
|
||||
<router-link to="/" class="text-gray-400 hover:text-white text-sm">← Back</router-link>
|
||||
<div class="flex gap-2">
|
||||
<button v-for="c in [1,2,3,4]" :key="c"
|
||||
@click="switchCourt(c)"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
|
||||
:class="courtId === c ? 'bg-green-500 text-white' : 'bg-gray-800 text-gray-400 hover:text-white'">
|
||||
Court {{ c }}
|
||||
</button>
|
||||
<button @click="mode = 'overview'"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
|
||||
:class="mode === 'overview' ? 'bg-blue-500 text-white' : 'bg-gray-800 text-gray-400 hover:text-white'">
|
||||
Overview
|
||||
</button>
|
||||
</div>
|
||||
<button @click="toggleFullscreen" class="ml-auto bg-gray-800 hover:bg-gray-700 text-white px-3 py-1.5 rounded-lg text-sm">
|
||||
⛶ Fullscreen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- COURT DISPLAY MODE -->
|
||||
<div v-if="mode === 'court'" class="h-screen flex flex-col bg-gray-950">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-8 pt-6 pb-4 bg-gray-900 border-b border-gray-800">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-4xl">🏓</span>
|
||||
<div>
|
||||
<div class="text-3xl font-black text-white">{{ courtData?.court?.name || `Court ${courtId}` }}</div>
|
||||
<div class="text-gray-400">{{ courtData?.court?.surface_type }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-400">ServeSync</div>
|
||||
<div class="text-lg font-bold text-green-400">🟢 LIVE</div>
|
||||
</div>
|
||||
<div class="text-right text-white">
|
||||
<div class="text-2xl font-mono">{{ currentTime }}</div>
|
||||
<div class="text-sm text-gray-400">{{ currentDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Match Display -->
|
||||
<div v-if="courtData?.active_match" class="flex-1 flex flex-col items-center justify-center px-8 py-6 gap-8">
|
||||
<!-- Stage Badge -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-5xl">{{ stageIcon(courtData.active_match.stage) }}</span>
|
||||
<div class="text-center">
|
||||
<span class="badge text-lg px-4 py-2 capitalize"
|
||||
:class="stageClass(courtData.active_match.stage)">
|
||||
{{ courtData.active_match.stage.replace('_', ' ').toUpperCase() }} MATCH
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scoreboard -->
|
||||
<div class="w-full max-w-5xl">
|
||||
<div class="grid grid-cols-3 gap-6 items-center">
|
||||
<!-- Team 1 -->
|
||||
<div class="text-center space-y-4">
|
||||
<div class="flex justify-center gap-3">
|
||||
<div v-for="p in courtData.active_match.team1" :key="p.id"
|
||||
class="flex flex-col items-center gap-2">
|
||||
<div class="w-20 h-20 rounded-full flex items-center justify-center text-4xl font-black text-white shadow-2xl"
|
||||
:style="{ backgroundColor: p.avatar_color, boxShadow: `0 0 30px ${p.avatar_color}40` }">
|
||||
{{ p.name[0] }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-white">{{ p.name.split(' ')[0] }}</div>
|
||||
<div class="text-sm capitalize px-2 py-0.5 rounded-full"
|
||||
:class="tierBadgeClass(p.membership_tier)">
|
||||
{{ p.membership_tier }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-9xl font-black text-white leading-none tabular-nums"
|
||||
:class="courtData.active_match.team1_score > courtData.active_match.team2_score ? 'text-green-400' : ''">
|
||||
{{ courtData.active_match.team1_score }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VS / Timer -->
|
||||
<div class="text-center space-y-4">
|
||||
<div class="text-4xl font-black text-gray-600">VS</div>
|
||||
<div v-if="courtData.active_match.elapsed_seconds" class="space-y-1">
|
||||
<div class="text-gray-400 text-sm">Elapsed</div>
|
||||
<div class="text-3xl font-mono text-white">{{ formatElapsed(courtData.active_match.elapsed_seconds) }}</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mt-4">
|
||||
<div class="h-2 rounded-full bg-gray-800 overflow-hidden">
|
||||
<div class="h-full bg-blue-500 transition-all duration-500 rounded-full"
|
||||
:style="{ width: team1Percent + '%' }"></div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 text-center">Score Progress</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team 2 -->
|
||||
<div class="text-center space-y-4">
|
||||
<div class="flex justify-center gap-3">
|
||||
<div v-for="p in courtData.active_match.team2" :key="p.id"
|
||||
class="flex flex-col items-center gap-2">
|
||||
<div class="w-20 h-20 rounded-full flex items-center justify-center text-4xl font-black text-white shadow-2xl"
|
||||
:style="{ backgroundColor: p.avatar_color, boxShadow: `0 0 30px ${p.avatar_color}40` }">
|
||||
{{ p.name[0] }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-white">{{ p.name.split(' ')[0] }}</div>
|
||||
<div class="text-sm capitalize px-2 py-0.5 rounded-full"
|
||||
:class="tierBadgeClass(p.membership_tier)">
|
||||
{{ p.membership_tier }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-9xl font-black text-white leading-none tabular-nums"
|
||||
:class="courtData.active_match.team2_score > courtData.active_match.team1_score ? 'text-green-400' : ''">
|
||||
{{ courtData.active_match.team2_score }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ELO Info -->
|
||||
<div class="flex gap-6 text-center">
|
||||
<div v-for="p in [...(courtData.active_match.team1 || []), ...(courtData.active_match.team2 || [])]"
|
||||
:key="p.id"
|
||||
class="bg-gray-900 border border-gray-700 rounded-xl px-4 py-2">
|
||||
<div class="text-xs text-gray-400">{{ p.name }}</div>
|
||||
<div class="text-lg font-bold text-white">{{ p.elo?.toFixed(0) }} ELO</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Court Display -->
|
||||
<div v-else class="flex-1 flex flex-col items-center justify-center gap-8 px-8">
|
||||
<div class="text-8xl">🟢</div>
|
||||
<div class="text-center">
|
||||
<div class="text-5xl font-black text-green-400 mb-4">COURT AVAILABLE</div>
|
||||
<div class="text-2xl text-gray-400">Book this court via ServeSync</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Matches -->
|
||||
<div v-if="courtData?.recent_matches?.length" class="w-full max-w-2xl">
|
||||
<div class="text-lg text-gray-400 text-center mb-4">Recent Matches</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="m in courtData.recent_matches" :key="m.team1?.[0]"
|
||||
class="flex items-center justify-between bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
|
||||
<span class="text-white">{{ m.team1?.join(' & ') }}</span>
|
||||
<span class="text-2xl font-bold" :class="m.winner === 'team1' ? 'text-green-400' : 'text-white'">{{ m.team1_score }}</span>
|
||||
<span class="text-gray-500">vs</span>
|
||||
<span class="text-2xl font-bold" :class="m.winner === 'team2' ? 'text-green-400' : 'text-white'">{{ m.team2_score }}</span>
|
||||
<span class="text-white">{{ m.team2?.join(' & ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="bg-gray-900 border-t border-gray-800 px-8 py-3 flex items-center justify-between text-sm text-gray-400">
|
||||
<span>🏓 ServeSync — Court Management System</span>
|
||||
<span>Auto-refresh every 3s</span>
|
||||
<span>{{ courtData?.court?.features }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OVERVIEW MODE -->
|
||||
<div v-else-if="mode === 'overview'" class="min-h-screen bg-gray-950 p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-4xl">🏓</span>
|
||||
<div>
|
||||
<div class="text-3xl font-black text-white">ServeSync Live</div>
|
||||
<div class="text-gray-400">All Courts Overview</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-mono text-white">{{ currentTime }}</div>
|
||||
<div class="text-gray-400">{{ currentDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Courts Grid -->
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div v-for="court in overview?.courts" :key="court.id"
|
||||
class="bg-gray-900 border rounded-2xl overflow-hidden"
|
||||
:class="court.status === 'occupied' ? 'border-green-700/50' : 'border-gray-700/50'">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b"
|
||||
:class="court.status === 'occupied' ? 'bg-green-900/20 border-green-800/30' : 'bg-gray-800/50 border-gray-700/50'">
|
||||
<span class="text-xl font-bold text-white">{{ court.name }}</span>
|
||||
<span :class="court.status === 'occupied' ? 'text-green-400' : 'text-gray-500'">
|
||||
{{ court.status === 'occupied' ? '🔴 MATCH IN PROGRESS' : '⚪ AVAILABLE' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="court.match" class="px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-center">
|
||||
<div class="text-sm text-gray-400 mb-1">{{ court.match.team1_names?.join(' & ') }}</div>
|
||||
<div class="text-6xl font-black" :class="court.match.team1_score > court.match.team2_score ? 'text-green-400' : 'text-white'">
|
||||
{{ court.match.team1_score }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-3xl text-gray-600 font-bold">VS</div>
|
||||
<div class="text-center">
|
||||
<div class="text-sm text-gray-400 mb-1">{{ court.match.team2_names?.join(' & ') }}</div>
|
||||
<div class="text-6xl font-black" :class="court.match.team2_score > court.match.team1_score ? 'text-green-400' : 'text-white'">
|
||||
{{ court.match.team2_score }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-5 py-8 text-center text-gray-600 text-lg">
|
||||
Ready for play
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard -->
|
||||
<div class="grid lg:grid-cols-2 gap-6">
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
|
||||
<div class="px-5 py-3 bg-gray-800/50 border-b border-gray-700/50">
|
||||
<span class="text-lg font-bold text-white">🏆 Top Players</span>
|
||||
</div>
|
||||
<div>
|
||||
<div v-for="(p, idx) in overview?.leaderboard" :key="p.name"
|
||||
class="flex items-center gap-4 px-5 py-3 border-b border-gray-800/50 last:border-0">
|
||||
<span class="text-xl w-8 text-center">{{ idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : idx + 1 }}</span>
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-lg font-bold text-white"
|
||||
:style="{ backgroundColor: p.avatar_color }">{{ p.name[0] }}</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-white font-medium">{{ p.name }}</div>
|
||||
<div class="text-sm text-gray-400">{{ p.wins }}W / {{ p.losses }}L</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold" :class="tierColorClass(p.tier)">{{ p.elo?.toFixed(0) }}</div>
|
||||
<div class="text-xs capitalize" :class="tierColorClass(p.tier)">{{ p.tier }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tournament Summary -->
|
||||
<div v-if="overview?.tournament" class="bg-gray-900 border border-purple-800/30 rounded-2xl overflow-hidden">
|
||||
<div class="px-5 py-3 bg-purple-900/20 border-b border-purple-800/30">
|
||||
<span class="text-lg font-bold text-white">🏆 {{ overview.tournament.tournament?.name }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<div v-for="entry in overview.tournament.leaderboard?.slice(0, 6)" :key="entry.player?.id"
|
||||
class="flex items-center gap-3">
|
||||
<span class="text-gray-500 w-4 text-sm">{{ entry.final_rank || '—' }}</span>
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: entry.player?.avatar_color || '#555' }">
|
||||
{{ entry.player?.name?.[0] || '?' }}
|
||||
</div>
|
||||
<span class="text-white text-sm flex-1">{{ entry.player?.name }}</span>
|
||||
<div class="flex gap-1">
|
||||
<span v-if="entry.is_eliminated" class="badge bg-red-900/30 text-red-400 text-xs">Eliminated</span>
|
||||
<span v-else-if="entry.is_in_losers" class="badge bg-orange-900/30 text-orange-400 text-xs">Losers Bracket</span>
|
||||
<span v-else class="badge bg-yellow-900/30 text-yellow-400 text-xs">Winners Bracket</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
|
||||
const courtId = ref(parseInt(route.params.courtId) || 1)
|
||||
const mode = ref('court')
|
||||
const courtData = ref(null)
|
||||
const overview = ref(null)
|
||||
const currentTime = ref('')
|
||||
const currentDate = ref('')
|
||||
const isFullscreen = ref(false)
|
||||
let refreshInterval = null
|
||||
let clockInterval = null
|
||||
|
||||
const team1Percent = computed(() => {
|
||||
const m = courtData.value?.active_match
|
||||
if (!m) return 50
|
||||
const total = m.team1_score + m.team2_score
|
||||
if (total === 0) return 50
|
||||
return Math.round((m.team1_score / total) * 100)
|
||||
})
|
||||
|
||||
const stageIcon = (stage) => {
|
||||
const icons = { open: '🎯', skill_based: '⚡', tournament: '🏆' }
|
||||
return icons[stage] || '🏓'
|
||||
}
|
||||
|
||||
const stageClass = (stage) => {
|
||||
const classes = {
|
||||
open: 'bg-blue-500/20 text-blue-400',
|
||||
skill_based: 'bg-orange-500/20 text-orange-400',
|
||||
tournament: 'bg-yellow-500/20 text-yellow-400',
|
||||
}
|
||||
return classes[stage] || 'bg-gray-700 text-gray-300'
|
||||
}
|
||||
|
||||
const tierBadgeClass = (tier) => {
|
||||
const classes = {
|
||||
bronze: 'bg-amber-900/60 text-amber-400',
|
||||
silver: 'bg-gray-700 text-gray-300',
|
||||
gold: 'bg-yellow-900/60 text-yellow-400',
|
||||
platinum: 'bg-cyan-900/60 text-cyan-400',
|
||||
elite: 'bg-purple-900/60 text-purple-400',
|
||||
}
|
||||
return classes[tier] || 'bg-gray-800 text-gray-400'
|
||||
}
|
||||
|
||||
const tierColorClass = (tier) => {
|
||||
const classes = {
|
||||
bronze: 'text-amber-600',
|
||||
silver: 'text-gray-400',
|
||||
gold: 'text-yellow-400',
|
||||
platinum: 'text-cyan-400',
|
||||
elite: 'text-purple-400',
|
||||
}
|
||||
return classes[tier] || 'text-gray-400'
|
||||
}
|
||||
|
||||
const formatElapsed = (seconds) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const switchCourt = (id) => {
|
||||
courtId.value = id
|
||||
mode.value = 'court'
|
||||
router.push(`/screen/${id}`)
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
isFullscreen.value = true
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
isFullscreen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateClock = () => {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('en-PH', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
currentDate.value = now.toLocaleDateString('en-PH', { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
if (mode.value === 'court') {
|
||||
courtData.value = await store.getCourtDisplay(courtId.value)
|
||||
} else {
|
||||
overview.value = await store.getOverview()
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updateClock()
|
||||
clockInterval = setInterval(updateClock, 1000)
|
||||
await refreshData()
|
||||
refreshInterval = setInterval(refreshData, 3000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) clearInterval(refreshInterval)
|
||||
if (clockInterval) clearInterval(clockInterval)
|
||||
})
|
||||
</script>
|
||||
@@ -1,330 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">🏆 Tournament</h1>
|
||||
<div v-if="tournament" class="text-sm text-gray-400 mt-1">
|
||||
{{ tournament.tournament?.name }}
|
||||
<span class="ml-2 badge capitalize"
|
||||
:class="tournament.tournament?.status === 'in_progress' ? 'bg-green-500/20 text-green-400' : 'bg-gray-700 text-gray-400'">
|
||||
{{ tournament.tournament?.status?.replace('_', ' ') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="loadData" class="btn btn-secondary text-sm">🔄 Refresh</button>
|
||||
<button v-if="!tournament" @click="showCreateModal = true" class="btn btn-primary text-sm">+ New Tournament</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="text-center py-12 text-gray-400">Loading tournament data...</div>
|
||||
|
||||
<!-- No Tournament -->
|
||||
<div v-else-if="!tournament && !loading" class="card text-center py-16">
|
||||
<div class="text-6xl mb-4">🏆</div>
|
||||
<h2 class="text-xl font-bold text-white mb-2">No Active Tournament</h2>
|
||||
<p class="text-gray-400 mb-6">Create a double elimination tournament to get started</p>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">Create Tournament</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tournament">
|
||||
<!-- Leaderboard / Rankings -->
|
||||
<div class="grid lg:grid-cols-4 gap-6">
|
||||
<div class="lg:col-span-3 space-y-6">
|
||||
<!-- Winners Bracket -->
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-400 inline-block"></span>
|
||||
Winners Bracket
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="flex gap-6 min-w-max pb-4">
|
||||
<div v-for="round in groupedWinners" :key="round.round" class="flex flex-col gap-4 min-w-48">
|
||||
<div class="text-center text-xs text-gray-400 font-medium pb-2 border-b border-gray-800">
|
||||
Round {{ round.round }}
|
||||
</div>
|
||||
<div v-for="match in round.matches" :key="match.id"
|
||||
class="bg-gray-900 border rounded-xl overflow-hidden transition-all"
|
||||
:class="matchBorderClass(match)">
|
||||
<div class="px-1 py-0.5 text-center text-xs"
|
||||
:class="match.status === 'in_progress' ? 'bg-green-500/20 text-green-400' : 'bg-gray-800 text-gray-500'">
|
||||
{{ match.status === 'completed' ? '✓ Done' : match.status === 'in_progress' ? '🔴 Live' : '⏳ Pending' }}
|
||||
</div>
|
||||
<div class="p-3 space-y-1">
|
||||
<MatchPlayer :player="match.player1" :score="match.team1_score" :is-winner="match.winner_team === 1" />
|
||||
<div class="text-center text-xs text-gray-600">vs</div>
|
||||
<MatchPlayer :player="match.player2" :score="match.team2_score" :is-winner="match.winner_team === 2" />
|
||||
</div>
|
||||
<div v-if="match.status === 'pending' && match.player1 && match.player2" class="px-3 pb-2">
|
||||
<button @click="openScore(match)" class="w-full text-xs bg-blue-600/30 hover:bg-blue-600/50 text-blue-400 rounded-lg py-1.5">
|
||||
Enter Score
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Losers Bracket -->
|
||||
<div v-if="tournament.losers_bracket?.length > 0">
|
||||
<h2 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-orange-400 inline-block"></span>
|
||||
Losers Bracket
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="flex gap-6 min-w-max pb-4">
|
||||
<div v-for="round in groupedLosers" :key="round.round" class="flex flex-col gap-4 min-w-48">
|
||||
<div class="text-center text-xs text-gray-400 font-medium pb-2 border-b border-gray-800">
|
||||
LB Round {{ round.round }}
|
||||
</div>
|
||||
<div v-for="match in round.matches" :key="match.id"
|
||||
class="bg-gray-900 border border-orange-900/40 rounded-xl overflow-hidden">
|
||||
<div class="p-3 space-y-1">
|
||||
<MatchPlayer :player="match.player1" :score="match.team1_score" :is-winner="match.winner_team === 1" />
|
||||
<div class="text-center text-xs text-gray-600">vs</div>
|
||||
<MatchPlayer :player="match.player2" :score="match.team2_score" :is-winner="match.winner_team === 2" />
|
||||
</div>
|
||||
<div v-if="match.status === 'pending' && match.player1 && match.player2" class="px-3 pb-2">
|
||||
<button @click="openScore(match)" class="w-full text-xs bg-orange-600/30 hover:bg-orange-600/50 text-orange-400 rounded-lg py-1.5">
|
||||
Enter Score
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grand Final -->
|
||||
<div v-if="tournament.grand_final?.length > 0">
|
||||
<h2 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span class="text-xl">🏆</span>
|
||||
Grand Final
|
||||
</h2>
|
||||
<div v-for="match in tournament.grand_final" :key="match.id"
|
||||
class="card border-yellow-700/50 bg-yellow-900/10 max-w-sm">
|
||||
<MatchPlayer :player="match.player1" :score="match.team1_score" :is-winner="match.winner_team === 1" />
|
||||
<div class="text-center text-gray-500 my-1 text-sm font-bold">GRAND FINAL</div>
|
||||
<MatchPlayer :player="match.player2" :score="match.team2_score" :is-winner="match.winner_team === 2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard Sidebar -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-bold text-white">📊 Standings</h2>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(entry, idx) in tournament.leaderboard" :key="entry.player?.id"
|
||||
class="flex items-center gap-3 bg-gray-900 rounded-xl p-3 border transition-all"
|
||||
:class="entry.is_eliminated ? 'border-gray-800 opacity-60' : 'border-gray-700'">
|
||||
<span class="text-lg font-bold w-6 text-center flex-shrink-0"
|
||||
:class="idx === 0 ? 'text-yellow-400' : idx === 1 ? 'text-gray-300' : idx === 2 ? 'text-amber-600' : 'text-gray-600'">
|
||||
{{ entry.final_rank || (entry.is_eliminated ? '✗' : '—') }}
|
||||
</span>
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
|
||||
:style="{ backgroundColor: entry.player?.avatar_color || '#555' }">
|
||||
{{ entry.player?.name?.[0] || '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-white truncate">{{ entry.player?.name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ entry.wins }}W / {{ entry.losses }}L</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<span v-if="entry.is_eliminated" class="badge bg-red-900/30 text-red-400 text-xs">Out</span>
|
||||
<span v-else-if="entry.is_in_losers" class="badge bg-orange-900/30 text-orange-400 text-xs">LB</span>
|
||||
<span v-else class="badge bg-yellow-900/30 text-yellow-400 text-xs">WB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration (if open) -->
|
||||
<div v-if="tournament.tournament?.status === 'registration'" class="card">
|
||||
<h3 class="font-bold text-white mb-3">Register Player</h3>
|
||||
<select v-model="registerPlayerId"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mb-3">
|
||||
<option v-for="p in availablePlayers" :key="p.id" :value="p.id">
|
||||
{{ p.name }} ({{ p.elo_rating.toFixed(0) }} ELO)
|
||||
</option>
|
||||
</select>
|
||||
<button @click="registerPlayer" class="btn btn-primary w-full text-sm">Register</button>
|
||||
<button @click="startTournament" class="btn btn-secondary w-full text-sm mt-2">▶ Start Tournament</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Tournament Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-bold text-white mb-4">🏆 Create Tournament</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Tournament Name</label>
|
||||
<input v-model="createForm.name" type="text" placeholder="ServeSync Grand Prix"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">Max Participants</label>
|
||||
<select v-model="createForm.max_participants"
|
||||
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1">
|
||||
<option value="4">4 players</option>
|
||||
<option value="8">8 players</option>
|
||||
<option value="16">16 players</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button @click="showCreateModal = false" class="btn btn-secondary flex-1">Cancel</button>
|
||||
<button @click="createTournament" class="btn btn-primary flex-1">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Modal -->
|
||||
<div v-if="scoreModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-full max-w-sm">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Enter Match Score</h3>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="text-center">
|
||||
<div class="text-sm text-gray-400 mb-2">{{ scoreModal.player1?.name }}</div>
|
||||
<input type="number" v-model="scoreForm.s1" min="0" max="21"
|
||||
class="w-full text-center text-3xl font-bold bg-gray-800 border border-gray-700 text-white rounded-xl py-3" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-sm text-gray-400 mb-2">{{ scoreModal.player2?.name }}</div>
|
||||
<input type="number" v-model="scoreForm.s2" min="0" max="21"
|
||||
class="w-full text-center text-3xl font-bold bg-gray-800 border border-gray-700 text-white rounded-xl py-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="scoreModal = null" class="btn btn-secondary flex-1">Cancel</button>
|
||||
<button @click="submitScore" class="btn btn-primary flex-1">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import axios from 'axios'
|
||||
|
||||
// Inline MatchPlayer component
|
||||
const MatchPlayer = {
|
||||
props: ['player', 'score', 'isWinner'],
|
||||
template: `
|
||||
<div class="flex items-center gap-2 px-2 py-1 rounded-lg" :class="isWinner ? 'bg-green-900/30' : ''">
|
||||
<div v-if="player" class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
|
||||
:style="{ backgroundColor: player.avatar_color || '#555' }">{{ player.name?.[0] }}</div>
|
||||
<div v-else class="w-6 h-6 rounded-full bg-gray-700 flex-shrink-0"></div>
|
||||
<span class="text-sm text-white flex-1 truncate">{{ player?.name || 'TBD' }}</span>
|
||||
<span v-if="score !== null && score !== undefined" class="text-sm font-bold" :class="isWinner ? 'text-green-400' : 'text-white'">{{ score }}</span>
|
||||
<span v-if="isWinner" class="text-xs">👑</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const store = useAppStore()
|
||||
const tournament = ref(null)
|
||||
const loading = ref(true)
|
||||
const showCreateModal = ref(false)
|
||||
const scoreModal = ref(null)
|
||||
const registerPlayerId = ref(null)
|
||||
const players = ref([])
|
||||
const scoreForm = reactive({ s1: 0, s2: 0 })
|
||||
const createForm = reactive({ name: 'ServeSync Grand Prix', max_participants: 8 })
|
||||
|
||||
const groupedWinners = computed(() => {
|
||||
if (!tournament.value?.winners_bracket) return []
|
||||
const rounds = {}
|
||||
for (const m of tournament.value.winners_bracket) {
|
||||
if (!rounds[m.round]) rounds[m.round] = { round: m.round, matches: [] }
|
||||
rounds[m.round].matches.push(m)
|
||||
}
|
||||
return Object.values(rounds).sort((a, b) => a.round - b.round)
|
||||
})
|
||||
|
||||
const groupedLosers = computed(() => {
|
||||
if (!tournament.value?.losers_bracket) return []
|
||||
const rounds = {}
|
||||
for (const m of tournament.value.losers_bracket) {
|
||||
if (!rounds[m.round]) rounds[m.round] = { round: m.round, matches: [] }
|
||||
rounds[m.round].matches.push(m)
|
||||
}
|
||||
return Object.values(rounds).sort((a, b) => a.round - b.round)
|
||||
})
|
||||
|
||||
const availablePlayers = computed(() => {
|
||||
const registeredIds = new Set(tournament.value?.leaderboard?.map(e => e.player?.id) || [])
|
||||
return players.value.filter(p => !registeredIds.has(p.id))
|
||||
})
|
||||
|
||||
const matchBorderClass = (match) => {
|
||||
if (match.status === 'completed') return 'border-green-800/30'
|
||||
if (match.status === 'in_progress') return 'border-green-500/50 shadow-green-500/20 shadow-lg'
|
||||
return 'border-gray-800'
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const tournaments = await store.loadTournaments()
|
||||
const active = tournaments.find(t => t.status === 'in_progress' || t.status === 'registration')
|
||||
if (active) {
|
||||
tournament.value = await store.loadTournament(active.id)
|
||||
}
|
||||
players.value = await store.loadPlayers()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createTournament = async () => {
|
||||
try {
|
||||
const res = await axios.post('/api/tournaments/', createForm)
|
||||
showCreateModal.value = false
|
||||
await loadData()
|
||||
} catch (e) { alert('Error creating tournament') }
|
||||
}
|
||||
|
||||
const registerPlayer = async () => {
|
||||
if (!registerPlayerId.value || !tournament.value) return
|
||||
try {
|
||||
await axios.post(`/api/tournaments/${tournament.value.tournament.id}/register`, {
|
||||
player_id: registerPlayerId.value
|
||||
})
|
||||
await loadData()
|
||||
} catch (e) { alert(e.response?.data?.detail || 'Error') }
|
||||
}
|
||||
|
||||
const startTournament = async () => {
|
||||
if (!tournament.value) return
|
||||
try {
|
||||
tournament.value = await axios.post(`/api/tournaments/${tournament.value.tournament.id}/start`).then(r => r.data)
|
||||
} catch (e) { alert(e.response?.data?.detail || 'Need at least 4 players') }
|
||||
}
|
||||
|
||||
const openScore = (match) => {
|
||||
scoreModal.value = match
|
||||
scoreForm.s1 = match.team1_score || 0
|
||||
scoreForm.s2 = match.team2_score || 0
|
||||
}
|
||||
|
||||
const submitScore = async () => {
|
||||
if (!scoreModal.value || !tournament.value) return
|
||||
try {
|
||||
tournament.value = await store.completeTournamentMatch(
|
||||
tournament.value.tournament.id,
|
||||
scoreModal.value.id,
|
||||
scoreForm.s1,
|
||||
scoreForm.s2
|
||||
)
|
||||
scoreModal.value = null
|
||||
} catch (e) { alert('Error submitting score') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
Reference in New Issue
Block a user