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:
@@ -2,17 +2,13 @@ FROM python:3.11-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||||
gcc \
|
|
||||||
libpq-dev \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN chmod +x /app/start.sh
|
|
||||||
|
|
||||||
EXPOSE 8000
|
RUN chmod +x start.sh
|
||||||
CMD ["/app/start.sh"]
|
|
||||||
|
CMD ["./start.sh"]
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
"""Admin/seed endpoints for demo"""
|
|
||||||
from fastapi import APIRouter, Depends, BackgroundTasks
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.player import Player, MembershipTier
|
|
||||||
from app.models.court import Court
|
|
||||||
from app.models.booking import Booking, BookingStatus
|
|
||||||
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
|
|
||||||
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus, BracketType, TournamentMatchStatus
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import random
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/seed")
|
|
||||||
def run_seed(db: Session = Depends(get_db)):
|
|
||||||
"""Run demo seed data"""
|
|
||||||
try:
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, '/app')
|
|
||||||
from seed import main
|
|
||||||
main()
|
|
||||||
return {"status": "ok", "message": "Seed complete"}
|
|
||||||
except Exception as e:
|
|
||||||
return {"status": "error", "message": str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/update-elos")
|
|
||||||
def update_elos(db: Session = Depends(get_db)):
|
|
||||||
"""Update demo player ELO ratings to proper values"""
|
|
||||||
elo_map = {
|
|
||||||
"marco@demo.com": (1850, MembershipTier.ELITE, 45, 8, "#EF4444"),
|
|
||||||
"sofia@demo.com": (1720, MembershipTier.ELITE, 38, 12, "#EC4899"),
|
|
||||||
"carlos@demo.com": (1630, MembershipTier.PLATINUM, 32, 15, "#8B5CF6"),
|
|
||||||
"ana@demo.com": (1580, MembershipTier.PLATINUM, 28, 18, "#06B6D4"),
|
|
||||||
"juan@demo.com": (1450, MembershipTier.GOLD, 22, 20, "#F59E0B"),
|
|
||||||
"maria@demo.com": (1380, MembershipTier.GOLD, 18, 22, "#10B981"),
|
|
||||||
"pedro@demo.com": (1250, MembershipTier.SILVER, 14, 19, "#3B82F6"),
|
|
||||||
"rosa@demo.com": (1190, MembershipTier.SILVER, 11, 21, "#F97316"),
|
|
||||||
"diego@demo.com": (1100, MembershipTier.SILVER, 9, 18, "#84CC16"),
|
|
||||||
"lucia@demo.com": (980, MembershipTier.BRONZE, 6, 20, "#14B8A6"),
|
|
||||||
"miguel@demo.com": (920, MembershipTier.BRONZE, 4, 15, "#6366F1"),
|
|
||||||
"isabella@demo.com": (850, MembershipTier.BRONZE, 2, 12, "#D946EF"),
|
|
||||||
}
|
|
||||||
|
|
||||||
updated = []
|
|
||||||
for email, (elo, tier, wins, losses, color) in elo_map.items():
|
|
||||||
p = db.query(Player).filter(Player.email == email).first()
|
|
||||||
if p:
|
|
||||||
p.elo_rating = elo
|
|
||||||
p.membership_tier = tier
|
|
||||||
p.wins = wins
|
|
||||||
p.losses = losses
|
|
||||||
p.total_matches = wins + losses
|
|
||||||
p.avatar_color = color
|
|
||||||
updated.append(p.name)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Create courts if not exist
|
|
||||||
if db.query(Court).count() == 0:
|
|
||||||
courts_data = [
|
|
||||||
("Court 1", 1, 200.0, "Sport Court", "LED Lighting, Pro Net System"),
|
|
||||||
("Court 2", 2, 200.0, "Sport Court", "LED Lighting, Spectator Seating"),
|
|
||||||
("Court 3", 3, 250.0, "Cushioned", "Tournament Grade, Scoreboards"),
|
|
||||||
("Court 4", 4, 250.0, "Cushioned", "Tournament Grade, VIP Lounge Access"),
|
|
||||||
]
|
|
||||||
for name, num, rate, surface, features in courts_data:
|
|
||||||
c = Court(name=name, court_number=num, hourly_rate=rate, surface_type=surface, features=features)
|
|
||||||
db.add(c)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Create active matches
|
|
||||||
courts = db.query(Court).order_by(Court.court_number).all()
|
|
||||||
players = db.query(Player).order_by(Player.elo_rating.desc()).all()
|
|
||||||
|
|
||||||
if db.query(Match).count() == 0 and len(players) >= 4 and len(courts) >= 2:
|
|
||||||
# Active match 1
|
|
||||||
m1 = Match(
|
|
||||||
court_id=courts[0].id,
|
|
||||||
stage=MatchStage.OPEN,
|
|
||||||
match_type=MatchType.DOUBLES,
|
|
||||||
status=MatchStatus.IN_PROGRESS,
|
|
||||||
title="Open Doubles - Court 1",
|
|
||||||
max_players=4,
|
|
||||||
team1_score=7,
|
|
||||||
team2_score=5,
|
|
||||||
started_at=datetime.utcnow() - timedelta(minutes=25),
|
|
||||||
)
|
|
||||||
db.add(m1)
|
|
||||||
db.flush()
|
|
||||||
for pid, team in [(players[0].id, 1), (players[1].id, 1), (players[2].id, 2), (players[3].id, 2)]:
|
|
||||||
db.add(MatchPlayer(match_id=m1.id, player_id=pid, team=team, elo_before=db.query(Player).filter(Player.id == pid).first().elo_rating))
|
|
||||||
|
|
||||||
# Active match 2 (skill-based)
|
|
||||||
m2 = Match(
|
|
||||||
court_id=courts[1].id,
|
|
||||||
stage=MatchStage.SKILL_BASED,
|
|
||||||
match_type=MatchType.DOUBLES,
|
|
||||||
status=MatchStatus.IN_PROGRESS,
|
|
||||||
title="Skill Match - Gold League",
|
|
||||||
max_players=4,
|
|
||||||
min_elo=1200,
|
|
||||||
max_elo=1600,
|
|
||||||
team1_score=3,
|
|
||||||
team2_score=6,
|
|
||||||
started_at=datetime.utcnow() - timedelta(minutes=15),
|
|
||||||
)
|
|
||||||
db.add(m2)
|
|
||||||
db.flush()
|
|
||||||
for pid, team in [(players[4].id, 1), (players[5].id, 1), (players[6].id, 2), (players[7].id, 2)]:
|
|
||||||
db.add(MatchPlayer(match_id=m2.id, player_id=pid, team=team, elo_before=db.query(Player).filter(Player.id == pid).first().elo_rating))
|
|
||||||
|
|
||||||
# Lobby matches
|
|
||||||
m3 = Match(stage=MatchStage.OPEN, match_type=MatchType.DOUBLES, status=MatchStatus.LOBBY,
|
|
||||||
title="Evening Open Game", max_players=4)
|
|
||||||
db.add(m3)
|
|
||||||
db.flush()
|
|
||||||
db.add(MatchPlayer(match_id=m3.id, player_id=players[8].id, team=1, elo_before=players[8].elo_rating))
|
|
||||||
|
|
||||||
m4 = Match(stage=MatchStage.SKILL_BASED, match_type=MatchType.DOUBLES, status=MatchStatus.LOBBY,
|
|
||||||
title="Gold Tier Challenge", max_players=4, min_elo=1200, max_elo=1600)
|
|
||||||
db.add(m4)
|
|
||||||
db.flush()
|
|
||||||
db.add(MatchPlayer(match_id=m4.id, player_id=players[4].id, team=1, elo_before=players[4].elo_rating))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Create tournament
|
|
||||||
if db.query(Tournament).count() == 0 and len(players) >= 8 and len(courts) >= 3:
|
|
||||||
t = Tournament(
|
|
||||||
name="ServeSync Grand Prix - March 2024",
|
|
||||||
status=TournamentStatus.IN_PROGRESS,
|
|
||||||
max_participants=8,
|
|
||||||
current_round=2,
|
|
||||||
started_at=datetime.utcnow() - timedelta(hours=2),
|
|
||||||
)
|
|
||||||
db.add(t)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
tp = players[:8]
|
|
||||||
entries = []
|
|
||||||
for i, player in enumerate(tp):
|
|
||||||
entry = TournamentEntry(tournament_id=t.id, player_id=player.id, seed=i+1, wins=0, losses=0)
|
|
||||||
db.add(entry)
|
|
||||||
entries.append(entry)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
r1 = [(tp[0].id, tp[7].id, 11, 5), (tp[1].id, tp[6].id, 11, 7),
|
|
||||||
(tp[2].id, tp[5].id, 8, 11), (tp[3].id, tp[4].id, 11, 9)]
|
|
||||||
|
|
||||||
winners = []
|
|
||||||
losers = []
|
|
||||||
for i, (p1, p2, s1, s2) in enumerate(r1):
|
|
||||||
tm = TournamentMatch(tournament_id=t.id, round_number=1, match_number=i+1,
|
|
||||||
bracket_type=BracketType.WINNERS, status=TournamentMatchStatus.COMPLETED,
|
|
||||||
player1_id=p1, player2_id=p2, team1_score=s1, team2_score=s2,
|
|
||||||
winner_team=1 if s1>s2 else 2,
|
|
||||||
completed_at=datetime.utcnow() - timedelta(hours=1, minutes=30))
|
|
||||||
db.add(tm)
|
|
||||||
w = p1 if s1>s2 else p2
|
|
||||||
l = p2 if s1>s2 else p1
|
|
||||||
winners.append(w)
|
|
||||||
losers.append(l)
|
|
||||||
for e in entries:
|
|
||||||
if e.player_id == w: e.wins += 1
|
|
||||||
elif e.player_id == l:
|
|
||||||
e.losses += 1
|
|
||||||
e.is_in_losers = True
|
|
||||||
|
|
||||||
# R2 winners - 1 completed, 1 active
|
|
||||||
wm1 = TournamentMatch(tournament_id=t.id, round_number=2, match_number=1,
|
|
||||||
bracket_type=BracketType.WINNERS, status=TournamentMatchStatus.COMPLETED,
|
|
||||||
player1_id=winners[0], player2_id=winners[1], team1_score=11, team2_score=8,
|
|
||||||
winner_team=1, completed_at=datetime.utcnow() - timedelta(minutes=45))
|
|
||||||
db.add(wm1)
|
|
||||||
for e in entries:
|
|
||||||
if e.player_id == winners[0]: e.wins += 1
|
|
||||||
elif e.player_id == winners[1]:
|
|
||||||
e.losses += 1
|
|
||||||
e.is_in_losers = True
|
|
||||||
|
|
||||||
wm2 = TournamentMatch(tournament_id=t.id, round_number=2, match_number=2,
|
|
||||||
bracket_type=BracketType.WINNERS, status=TournamentMatchStatus.IN_PROGRESS,
|
|
||||||
player1_id=winners[2], player2_id=winners[3], team1_score=6, team2_score=7)
|
|
||||||
db.add(wm2)
|
|
||||||
|
|
||||||
# Losers bracket
|
|
||||||
lb1 = TournamentMatch(tournament_id=t.id, round_number=1, match_number=1,
|
|
||||||
bracket_type=BracketType.LOSERS, status=TournamentMatchStatus.COMPLETED,
|
|
||||||
player1_id=losers[0], player2_id=losers[1], team1_score=9, team2_score=11,
|
|
||||||
winner_team=2, completed_at=datetime.utcnow() - timedelta(minutes=90))
|
|
||||||
db.add(lb1)
|
|
||||||
lb2 = TournamentMatch(tournament_id=t.id, round_number=1, match_number=2,
|
|
||||||
bracket_type=BracketType.LOSERS, status=TournamentMatchStatus.COMPLETED,
|
|
||||||
player1_id=losers[2], player2_id=losers[3], team1_score=11, team2_score=6,
|
|
||||||
winner_team=1, completed_at=datetime.utcnow() - timedelta(minutes=60))
|
|
||||||
db.add(lb2)
|
|
||||||
|
|
||||||
for e in entries:
|
|
||||||
if e.player_id == losers[0]:
|
|
||||||
e.losses += 1
|
|
||||||
e.is_eliminated = True
|
|
||||||
e.final_rank = 8
|
|
||||||
elif e.player_id == losers[3]:
|
|
||||||
e.losses += 1
|
|
||||||
e.is_eliminated = True
|
|
||||||
e.final_rank = 7
|
|
||||||
|
|
||||||
# Tournament match on court 3
|
|
||||||
p_name_2 = db.query(Player).filter(Player.id == winners[2]).first().name
|
|
||||||
p_name_3 = db.query(Player).filter(Player.id == winners[3]).first().name
|
|
||||||
m_tour = Match(
|
|
||||||
court_id=courts[2].id, stage=MatchStage.TOURNAMENT, match_type=MatchType.SINGLES,
|
|
||||||
status=MatchStatus.IN_PROGRESS,
|
|
||||||
title=f"Tournament R2 - {p_name_2} vs {p_name_3}",
|
|
||||||
max_players=2, team1_score=6, team2_score=7,
|
|
||||||
started_at=datetime.utcnow() - timedelta(minutes=20),
|
|
||||||
)
|
|
||||||
db.add(m_tour)
|
|
||||||
db.flush()
|
|
||||||
for pid, team in [(winners[2], 1), (winners[3], 2)]:
|
|
||||||
db.add(MatchPlayer(match_id=m_tour.id, player_id=pid, team=team,
|
|
||||||
elo_before=db.query(Player).filter(Player.id == pid).first().elo_rating))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"status": "ok", "updated": updated, "players": len(updated)}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy import and_
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.court import Court
|
|
||||||
from app.models.booking import Booking, BookingStatus
|
|
||||||
from app.models.player import Player
|
|
||||||
from app.models.match import Match, MatchStatus
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, List
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/courts", tags=["courts"])
|
|
||||||
|
|
||||||
|
|
||||||
class BookingCreate(BaseModel):
|
|
||||||
player_id: int
|
|
||||||
court_id: int
|
|
||||||
start_time: datetime
|
|
||||||
duration_hours: float = 1.0
|
|
||||||
|
|
||||||
|
|
||||||
class CourtResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
court_number: int
|
|
||||||
hourly_rate: float
|
|
||||||
is_active: bool
|
|
||||||
surface_type: str
|
|
||||||
features: str
|
|
||||||
current_status: str
|
|
||||||
current_match: Optional[dict] = None
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class BookingResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
player_id: int
|
|
||||||
player_name: str
|
|
||||||
court_id: int
|
|
||||||
court_name: str
|
|
||||||
start_time: datetime
|
|
||||||
end_time: datetime
|
|
||||||
duration_hours: float
|
|
||||||
total_cost: float
|
|
||||||
status: str
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
def get_court_status(court: Court, db: Session) -> tuple:
|
|
||||||
"""Get current status of a court"""
|
|
||||||
# Check if match is in progress
|
|
||||||
match = db.query(Match).filter(
|
|
||||||
Match.court_id == court.id,
|
|
||||||
Match.status == MatchStatus.IN_PROGRESS
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if match:
|
|
||||||
team1 = [mp.player.name for mp in match.match_players if mp.team == 1]
|
|
||||||
team2 = [mp.player.name for mp in match.match_players if mp.team == 2]
|
|
||||||
return "occupied", {
|
|
||||||
"match_id": match.id,
|
|
||||||
"team1": team1,
|
|
||||||
"team2": team2,
|
|
||||||
"team1_score": match.team1_score,
|
|
||||||
"team2_score": match.team2_score,
|
|
||||||
"stage": match.stage.value,
|
|
||||||
"started_at": match.started_at.isoformat() if match.started_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if there's a booking now
|
|
||||||
now = datetime.utcnow()
|
|
||||||
booking = db.query(Booking).filter(
|
|
||||||
Booking.court_id == court.id,
|
|
||||||
Booking.start_time <= now,
|
|
||||||
Booking.end_time >= now,
|
|
||||||
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS])
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if booking:
|
|
||||||
return "booked", None
|
|
||||||
|
|
||||||
return "available", None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[CourtResponse])
|
|
||||||
def get_courts(db: Session = Depends(get_db)):
|
|
||||||
courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all()
|
|
||||||
result = []
|
|
||||||
for court in courts:
|
|
||||||
status, match_info = get_court_status(court, db)
|
|
||||||
result.append(CourtResponse(
|
|
||||||
id=court.id,
|
|
||||||
name=court.name,
|
|
||||||
court_number=court.court_number,
|
|
||||||
hourly_rate=court.hourly_rate,
|
|
||||||
is_active=court.is_active,
|
|
||||||
surface_type=court.surface_type,
|
|
||||||
features=court.features,
|
|
||||||
current_status=status,
|
|
||||||
current_match=match_info,
|
|
||||||
))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{court_id}")
|
|
||||||
def get_court(court_id: int, db: Session = Depends(get_db)):
|
|
||||||
court = db.query(Court).filter(Court.id == court_id).first()
|
|
||||||
if not court:
|
|
||||||
raise HTTPException(status_code=404, detail="Court not found")
|
|
||||||
status, match_info = get_court_status(court, db)
|
|
||||||
return {
|
|
||||||
"id": court.id,
|
|
||||||
"name": court.name,
|
|
||||||
"court_number": court.court_number,
|
|
||||||
"hourly_rate": court.hourly_rate,
|
|
||||||
"surface_type": court.surface_type,
|
|
||||||
"features": court.features,
|
|
||||||
"current_status": status,
|
|
||||||
"current_match": match_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{court_id}/schedule")
|
|
||||||
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
|
|
||||||
"""Get bookings for a court for the next 7 days"""
|
|
||||||
now = datetime.utcnow()
|
|
||||||
end = now + timedelta(days=7)
|
|
||||||
bookings = db.query(Booking).filter(
|
|
||||||
Booking.court_id == court_id,
|
|
||||||
Booking.start_time >= now,
|
|
||||||
Booking.end_time <= end,
|
|
||||||
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS])
|
|
||||||
).order_by(Booking.start_time).all()
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": b.id,
|
|
||||||
"player_name": b.player.name,
|
|
||||||
"start_time": b.start_time.isoformat(),
|
|
||||||
"end_time": b.end_time.isoformat(),
|
|
||||||
"duration_hours": b.duration_hours,
|
|
||||||
"is_match_booking": b.is_match_booking,
|
|
||||||
}
|
|
||||||
for b in bookings
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/book")
|
|
||||||
def book_court(booking_data: BookingCreate, db: Session = Depends(get_db)):
|
|
||||||
court = db.query(Court).filter(Court.id == booking_data.court_id).first()
|
|
||||||
if not court:
|
|
||||||
raise HTTPException(status_code=404, detail="Court not found")
|
|
||||||
|
|
||||||
player = db.query(Player).filter(Player.id == booking_data.player_id).first()
|
|
||||||
if not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
|
||||||
|
|
||||||
end_time = booking_data.start_time + timedelta(hours=booking_data.duration_hours)
|
|
||||||
|
|
||||||
# Check for conflicts
|
|
||||||
conflict = db.query(Booking).filter(
|
|
||||||
Booking.court_id == booking_data.court_id,
|
|
||||||
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS]),
|
|
||||||
and_(Booking.start_time < end_time, Booking.end_time > booking_data.start_time)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if conflict:
|
|
||||||
raise HTTPException(status_code=400, detail="Court is already booked for this time slot")
|
|
||||||
|
|
||||||
total_cost = court.hourly_rate * booking_data.duration_hours
|
|
||||||
booking = Booking(
|
|
||||||
player_id=booking_data.player_id,
|
|
||||||
court_id=booking_data.court_id,
|
|
||||||
start_time=booking_data.start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
duration_hours=booking_data.duration_hours,
|
|
||||||
total_cost=total_cost,
|
|
||||||
status=BookingStatus.CONFIRMED,
|
|
||||||
)
|
|
||||||
db.add(booking)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(booking)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": booking.id,
|
|
||||||
"court_name": court.name,
|
|
||||||
"player_name": player.name,
|
|
||||||
"start_time": booking.start_time.isoformat(),
|
|
||||||
"end_time": booking.end_time.isoformat(),
|
|
||||||
"total_cost": booking.total_cost,
|
|
||||||
"status": booking.status.value,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bookings/upcoming")
|
|
||||||
def get_upcoming_bookings(db: Session = Depends(get_db)):
|
|
||||||
now = datetime.utcnow()
|
|
||||||
bookings = db.query(Booking).filter(
|
|
||||||
Booking.start_time >= now,
|
|
||||||
Booking.status == BookingStatus.CONFIRMED
|
|
||||||
).order_by(Booking.start_time).limit(20).all()
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": b.id,
|
|
||||||
"player_name": b.player.name,
|
|
||||||
"court_name": b.court.name,
|
|
||||||
"start_time": b.start_time.isoformat(),
|
|
||||||
"end_time": b.end_time.isoformat(),
|
|
||||||
"total_cost": b.total_cost,
|
|
||||||
}
|
|
||||||
for b in bookings
|
|
||||||
]
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
|
|
||||||
from app.models.player import Player
|
|
||||||
from app.services import matchmaking
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, List
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/matches", tags=["matches"])
|
|
||||||
|
|
||||||
|
|
||||||
class CreateMatchRequest(BaseModel):
|
|
||||||
title: str
|
|
||||||
stage: str = "open"
|
|
||||||
match_type: str = "doubles"
|
|
||||||
creator_player_id: int
|
|
||||||
elo_tolerance: float = 200
|
|
||||||
|
|
||||||
|
|
||||||
class JoinMatchRequest(BaseModel):
|
|
||||||
player_id: int
|
|
||||||
team: int
|
|
||||||
|
|
||||||
|
|
||||||
class ScoreUpdateRequest(BaseModel):
|
|
||||||
team1_score: int
|
|
||||||
team2_score: int
|
|
||||||
|
|
||||||
|
|
||||||
def match_to_dict(match: Match, db: Session) -> dict:
|
|
||||||
players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match.id).all()
|
|
||||||
team1 = []
|
|
||||||
team2 = []
|
|
||||||
for mp in players:
|
|
||||||
p = db.query(Player).filter(Player.id == mp.player_id).first()
|
|
||||||
if p:
|
|
||||||
pdata = {
|
|
||||||
"id": p.id,
|
|
||||||
"name": p.name,
|
|
||||||
"elo": round(p.elo_rating, 1),
|
|
||||||
"avatar_color": p.avatar_color,
|
|
||||||
"elo_change": mp.elo_change,
|
|
||||||
}
|
|
||||||
if mp.team == 1:
|
|
||||||
team1.append(pdata)
|
|
||||||
else:
|
|
||||||
team2.append(pdata)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": match.id,
|
|
||||||
"title": match.title,
|
|
||||||
"stage": match.stage.value,
|
|
||||||
"match_type": match.match_type.value,
|
|
||||||
"status": match.status.value,
|
|
||||||
"team1": team1,
|
|
||||||
"team2": team2,
|
|
||||||
"team1_score": match.team1_score,
|
|
||||||
"team2_score": match.team2_score,
|
|
||||||
"max_players": match.max_players,
|
|
||||||
"current_players": len(players),
|
|
||||||
"min_elo": match.min_elo,
|
|
||||||
"max_elo": match.max_elo,
|
|
||||||
"court_id": match.court_id,
|
|
||||||
"court_name": match.court.name if match.court else None,
|
|
||||||
"started_at": match.started_at.isoformat() if match.started_at else None,
|
|
||||||
"ended_at": match.ended_at.isoformat() if match.ended_at else None,
|
|
||||||
"created_at": match.created_at.isoformat() if match.created_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
def get_matches(status: Optional[str] = None, stage: Optional[str] = None, db: Session = Depends(get_db)):
|
|
||||||
query = db.query(Match)
|
|
||||||
if status:
|
|
||||||
query = query.filter(Match.status == status)
|
|
||||||
if stage:
|
|
||||||
query = query.filter(Match.stage == stage)
|
|
||||||
matches = query.order_by(Match.created_at.desc()).limit(50).all()
|
|
||||||
return [match_to_dict(m, db) for m in matches]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lobby")
|
|
||||||
def get_lobby(db: Session = Depends(get_db)):
|
|
||||||
matches = matchmaking.get_lobby_matches(db)
|
|
||||||
return [match_to_dict(m, db) for m in matches]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/active")
|
|
||||||
def get_active_matches(db: Session = Depends(get_db)):
|
|
||||||
matches = db.query(Match).filter(Match.status == MatchStatus.IN_PROGRESS).all()
|
|
||||||
return [match_to_dict(m, db) for m in matches]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{match_id}")
|
|
||||||
def get_match(match_id: int, db: Session = Depends(get_db)):
|
|
||||||
match = db.query(Match).filter(Match.id == match_id).first()
|
|
||||||
if not match:
|
|
||||||
raise HTTPException(status_code=404, detail="Match not found")
|
|
||||||
return match_to_dict(match, db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
def create_match(req: CreateMatchRequest, db: Session = Depends(get_db)):
|
|
||||||
player = db.query(Player).filter(Player.id == req.creator_player_id).first()
|
|
||||||
if not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
|
||||||
|
|
||||||
match_type = MatchType.DOUBLES if req.match_type == "doubles" else MatchType.SINGLES
|
|
||||||
|
|
||||||
if req.stage == "open":
|
|
||||||
match = matchmaking.create_open_match(db, req.title, match_type)
|
|
||||||
elif req.stage == "skill_based":
|
|
||||||
match = matchmaking.create_skill_match(db, req.title, player.elo_rating, match_type, req.elo_tolerance)
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid stage. Use 'open' or 'skill_based'")
|
|
||||||
|
|
||||||
# Auto-join creator to team 1
|
|
||||||
matchmaking.join_match(db, match.id, player.id, 1)
|
|
||||||
return match_to_dict(match, db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{match_id}/join")
|
|
||||||
def join_match(match_id: int, req: JoinMatchRequest, db: Session = Depends(get_db)):
|
|
||||||
result = matchmaking.join_match(db, match_id, req.player_id, req.team)
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot join match (full, ELO constraint, or already joined)")
|
|
||||||
|
|
||||||
match = db.query(Match).filter(Match.id == match_id).first()
|
|
||||||
return match_to_dict(match, db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{match_id}/start")
|
|
||||||
def start_match(match_id: int, db: Session = Depends(get_db)):
|
|
||||||
match = matchmaking.start_match(db, match_id)
|
|
||||||
if not match:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot start match (not enough players or already started)")
|
|
||||||
return match_to_dict(match, db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{match_id}/score")
|
|
||||||
def update_score(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)):
|
|
||||||
match = db.query(Match).filter(Match.id == match_id).first()
|
|
||||||
if not match:
|
|
||||||
raise HTTPException(status_code=404, detail="Match not found")
|
|
||||||
if match.status != MatchStatus.IN_PROGRESS:
|
|
||||||
raise HTTPException(status_code=400, detail="Match not in progress")
|
|
||||||
|
|
||||||
match.team1_score = req.team1_score
|
|
||||||
match.team2_score = req.team2_score
|
|
||||||
db.commit()
|
|
||||||
return match_to_dict(match, db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{match_id}/complete")
|
|
||||||
def complete_match(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)):
|
|
||||||
match = matchmaking.complete_match(db, match_id, req.team1_score, req.team2_score)
|
|
||||||
if not match:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot complete match")
|
|
||||||
return match_to_dict(match, db)
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.player import Player, MembershipTier
|
|
||||||
from app.services.elo import get_tier_from_elo
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, List
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/players", tags=["players"])
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
email: str
|
|
||||||
phone: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerUpdate(BaseModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
email: str
|
|
||||||
phone: Optional[str]
|
|
||||||
elo_rating: float
|
|
||||||
membership_tier: str
|
|
||||||
wins: int
|
|
||||||
losses: int
|
|
||||||
total_matches: int
|
|
||||||
win_rate: float
|
|
||||||
avatar_color: str
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_orm(cls, player: Player):
|
|
||||||
return cls(
|
|
||||||
id=player.id,
|
|
||||||
name=player.name,
|
|
||||||
email=player.email,
|
|
||||||
phone=player.phone,
|
|
||||||
elo_rating=round(player.elo_rating, 1),
|
|
||||||
membership_tier=player.membership_tier.value,
|
|
||||||
wins=player.wins,
|
|
||||||
losses=player.losses,
|
|
||||||
total_matches=player.total_matches,
|
|
||||||
win_rate=player.win_rate,
|
|
||||||
avatar_color=player.avatar_color,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[PlayerResponse])
|
|
||||||
def get_players(db: Session = Depends(get_db)):
|
|
||||||
players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).all()
|
|
||||||
return [PlayerResponse.from_orm(p) for p in players]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/leaderboard", response_model=List[PlayerResponse])
|
|
||||||
def get_leaderboard(limit: int = 10, db: Session = Depends(get_db)):
|
|
||||||
players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).limit(limit).all()
|
|
||||||
return [PlayerResponse.from_orm(p) for p in players]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{player_id}", response_model=PlayerResponse)
|
|
||||||
def get_player(player_id: int, db: Session = Depends(get_db)):
|
|
||||||
player = db.query(Player).filter(Player.id == player_id).first()
|
|
||||||
if not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
|
||||||
return PlayerResponse.from_orm(player)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=PlayerResponse)
|
|
||||||
def create_player(player_data: PlayerCreate, db: Session = Depends(get_db)):
|
|
||||||
existing = db.query(Player).filter(Player.email == player_data.email).first()
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(status_code=400, detail="Email already registered")
|
|
||||||
|
|
||||||
colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
|
|
||||||
color = colors[db.query(Player).count() % len(colors)]
|
|
||||||
|
|
||||||
player = Player(
|
|
||||||
name=player_data.name,
|
|
||||||
email=player_data.email,
|
|
||||||
phone=player_data.phone,
|
|
||||||
avatar_color=color,
|
|
||||||
)
|
|
||||||
db.add(player)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(player)
|
|
||||||
return PlayerResponse.from_orm(player)
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"""Screen Display API - for TV/display views"""
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy import desc
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.court import Court
|
|
||||||
from app.models.match import Match, MatchPlayer, MatchStatus
|
|
||||||
from app.models.player import Player
|
|
||||||
from app.models.tournament import Tournament, TournamentStatus
|
|
||||||
from app.services.tournament import get_bracket_data
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/screen", tags=["screen"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/court/{court_id}")
|
|
||||||
def get_court_display(court_id: int, db: Session = Depends(get_db)):
|
|
||||||
"""Full court display data for TV screen (court_id = court_number)"""
|
|
||||||
# Try by court_number first (friendlier URLs), then by id
|
|
||||||
court = db.query(Court).filter(Court.court_number == court_id).first()
|
|
||||||
if not court:
|
|
||||||
court = db.query(Court).filter(Court.id == court_id).first()
|
|
||||||
if not court:
|
|
||||||
return {"error": "Court not found"}
|
|
||||||
|
|
||||||
# Current active match
|
|
||||||
active_match = db.query(Match).filter(
|
|
||||||
Match.court_id == court_id,
|
|
||||||
Match.status == MatchStatus.IN_PROGRESS
|
|
||||||
).first()
|
|
||||||
|
|
||||||
match_data = None
|
|
||||||
if active_match:
|
|
||||||
team1_players = []
|
|
||||||
team2_players = []
|
|
||||||
for mp in active_match.match_players:
|
|
||||||
p = db.query(Player).filter(Player.id == mp.player_id).first()
|
|
||||||
if p:
|
|
||||||
pd = {
|
|
||||||
"id": p.id,
|
|
||||||
"name": p.name,
|
|
||||||
"elo": round(p.elo_rating, 1),
|
|
||||||
"avatar_color": p.avatar_color,
|
|
||||||
"membership_tier": p.membership_tier.value,
|
|
||||||
}
|
|
||||||
if mp.team == 1:
|
|
||||||
team1_players.append(pd)
|
|
||||||
else:
|
|
||||||
team2_players.append(pd)
|
|
||||||
|
|
||||||
elapsed = None
|
|
||||||
if active_match.started_at:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
started = active_match.started_at
|
|
||||||
if started.tzinfo is None:
|
|
||||||
started = started.replace(tzinfo=timezone.utc)
|
|
||||||
elapsed = int((now - started).total_seconds())
|
|
||||||
|
|
||||||
match_data = {
|
|
||||||
"id": active_match.id,
|
|
||||||
"stage": active_match.stage.value,
|
|
||||||
"match_type": active_match.match_type.value,
|
|
||||||
"team1": team1_players,
|
|
||||||
"team2": team2_players,
|
|
||||||
"team1_score": active_match.team1_score,
|
|
||||||
"team2_score": active_match.team2_score,
|
|
||||||
"elapsed_seconds": elapsed,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Recent completed matches on this court
|
|
||||||
recent_matches = db.query(Match).filter(
|
|
||||||
Match.court_id == court_id,
|
|
||||||
Match.status == MatchStatus.COMPLETED
|
|
||||||
).order_by(desc(Match.ended_at)).limit(3).all()
|
|
||||||
|
|
||||||
recent = []
|
|
||||||
for m in recent_matches:
|
|
||||||
team1 = [db.query(Player).filter(Player.id == mp.player_id).first().name
|
|
||||||
for mp in m.match_players if mp.team == 1]
|
|
||||||
team2 = [db.query(Player).filter(Player.id == mp.player_id).first().name
|
|
||||||
for mp in m.match_players if mp.team == 2]
|
|
||||||
recent.append({
|
|
||||||
"team1": team1,
|
|
||||||
"team2": team2,
|
|
||||||
"team1_score": m.team1_score,
|
|
||||||
"team2_score": m.team2_score,
|
|
||||||
"winner": "team1" if m.team1_score > m.team2_score else "team2",
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"court": {
|
|
||||||
"id": court.id,
|
|
||||||
"name": court.name,
|
|
||||||
"court_number": court.court_number,
|
|
||||||
"surface_type": court.surface_type,
|
|
||||||
},
|
|
||||||
"active_match": match_data,
|
|
||||||
"recent_matches": recent,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview")
|
|
||||||
def get_overview(db: Session = Depends(get_db)):
|
|
||||||
"""All courts overview for main display"""
|
|
||||||
courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all()
|
|
||||||
result = []
|
|
||||||
for court in courts:
|
|
||||||
active_match = db.query(Match).filter(
|
|
||||||
Match.court_id == court.id,
|
|
||||||
Match.status == MatchStatus.IN_PROGRESS
|
|
||||||
).first()
|
|
||||||
|
|
||||||
court_data = {
|
|
||||||
"id": court.id,
|
|
||||||
"name": court.name,
|
|
||||||
"court_number": court.court_number,
|
|
||||||
"status": "occupied" if active_match else "available",
|
|
||||||
}
|
|
||||||
|
|
||||||
if active_match:
|
|
||||||
team1 = [mp.player.name for mp in active_match.match_players if mp.team == 1]
|
|
||||||
team2 = [mp.player.name for mp in active_match.match_players if mp.team == 2]
|
|
||||||
court_data["match"] = {
|
|
||||||
"team1_names": team1,
|
|
||||||
"team2_names": team2,
|
|
||||||
"team1_score": active_match.team1_score,
|
|
||||||
"team2_score": active_match.team2_score,
|
|
||||||
"stage": active_match.stage.value,
|
|
||||||
}
|
|
||||||
|
|
||||||
result.append(court_data)
|
|
||||||
|
|
||||||
# Active tournament
|
|
||||||
tournament_data = None
|
|
||||||
active_tournament = db.query(Tournament).filter(
|
|
||||||
Tournament.status == TournamentStatus.IN_PROGRESS
|
|
||||||
).first()
|
|
||||||
if active_tournament:
|
|
||||||
tournament_data = get_bracket_data(db, active_tournament.id)
|
|
||||||
|
|
||||||
# Leaderboard top 5
|
|
||||||
top_players = db.query(Player).filter(Player.is_active == True).order_by(
|
|
||||||
Player.elo_rating.desc()
|
|
||||||
).limit(5).all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"courts": result,
|
|
||||||
"tournament": tournament_data,
|
|
||||||
"leaderboard": [
|
|
||||||
{
|
|
||||||
"rank": i + 1,
|
|
||||||
"name": p.name,
|
|
||||||
"elo": round(p.elo_rating, 1),
|
|
||||||
"tier": p.membership_tier.value,
|
|
||||||
"wins": p.wins,
|
|
||||||
"losses": p.losses,
|
|
||||||
"avatar_color": p.avatar_color,
|
|
||||||
}
|
|
||||||
for i, p in enumerate(top_players)
|
|
||||||
],
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus
|
|
||||||
from app.services import tournament as tournament_service
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, List
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tournaments", tags=["tournaments"])
|
|
||||||
|
|
||||||
|
|
||||||
class CreateTournamentRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
max_participants: int = 8
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterPlayerRequest(BaseModel):
|
|
||||||
player_id: int
|
|
||||||
|
|
||||||
|
|
||||||
class MatchScoreRequest(BaseModel):
|
|
||||||
team1_score: int
|
|
||||||
team2_score: int
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
def get_tournaments(db: Session = Depends(get_db)):
|
|
||||||
tournaments = db.query(Tournament).order_by(Tournament.created_at.desc()).all()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": t.id,
|
|
||||||
"name": t.name,
|
|
||||||
"status": t.status.value,
|
|
||||||
"max_participants": t.max_participants,
|
|
||||||
"current_participants": len(t.entries),
|
|
||||||
"started_at": t.started_at.isoformat() if t.started_at else None,
|
|
||||||
}
|
|
||||||
for t in tournaments
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tournament_id}")
|
|
||||||
def get_tournament(tournament_id: int, db: Session = Depends(get_db)):
|
|
||||||
return tournament_service.get_bracket_data(db, tournament_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
def create_tournament(req: CreateTournamentRequest, db: Session = Depends(get_db)):
|
|
||||||
t = tournament_service.create_tournament(db, req.name, req.max_participants)
|
|
||||||
return {"id": t.id, "name": t.name, "status": t.status.value}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{tournament_id}/register")
|
|
||||||
def register_player(tournament_id: int, req: RegisterPlayerRequest, db: Session = Depends(get_db)):
|
|
||||||
entry = tournament_service.register_player(db, tournament_id, req.player_id)
|
|
||||||
if not entry:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot register player (tournament full or already registered)")
|
|
||||||
return {"message": "Registered successfully", "player_id": req.player_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{tournament_id}/start")
|
|
||||||
def start_tournament(tournament_id: int, db: Session = Depends(get_db)):
|
|
||||||
t = tournament_service.start_tournament(db, tournament_id)
|
|
||||||
if not t:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot start tournament (need at least 4 players)")
|
|
||||||
return tournament_service.get_bracket_data(db, tournament_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{tournament_id}/matches/{match_id}/score")
|
|
||||||
def complete_tournament_match(
|
|
||||||
tournament_id: int, match_id: int, req: MatchScoreRequest, db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
tm = tournament_service.complete_tournament_match(db, match_id, req.team1_score, req.team2_score)
|
|
||||||
if not tm:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot complete match")
|
|
||||||
return tournament_service.get_bracket_data(db, tournament_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tournament_id}/leaderboard")
|
|
||||||
def get_leaderboard(tournament_id: int, db: Session = Depends(get_db)):
|
|
||||||
data = tournament_service.get_bracket_data(db, tournament_id)
|
|
||||||
return data.get("leaderboard", [])
|
|
||||||
148
backend/app/bracket.py
Normal file
148
backend/app/bracket.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""
|
||||||
|
Bracket and schedule generation logic for ServeSync
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_stages(num_players: int, format: str, courts: int) -> dict:
|
||||||
|
"""Calculate suggested stages and rounds for an event"""
|
||||||
|
if format == "Doubles":
|
||||||
|
teams = num_players // 2
|
||||||
|
else:
|
||||||
|
teams = num_players
|
||||||
|
|
||||||
|
matches_per_round = courts
|
||||||
|
|
||||||
|
if teams <= 4:
|
||||||
|
suggested_stages = 2
|
||||||
|
stage_names = ["Open Stage", "Tournament"]
|
||||||
|
elif teams <= 8:
|
||||||
|
suggested_stages = 2
|
||||||
|
stage_names = ["Open Stage", "Tournament"]
|
||||||
|
else:
|
||||||
|
suggested_stages = 3
|
||||||
|
stage_names = ["Open Stage", "Skill Stage", "Tournament"]
|
||||||
|
|
||||||
|
rounds_single_elim = math.ceil(math.log2(max(teams, 2)))
|
||||||
|
rounds_double_elim = rounds_single_elim * 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
"teams": teams,
|
||||||
|
"matches_per_round": matches_per_round,
|
||||||
|
"rounds_single_elim": rounds_single_elim,
|
||||||
|
"rounds_double_elim": rounds_double_elim,
|
||||||
|
"suggested_stages": suggested_stages,
|
||||||
|
"stage_names": stage_names,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_round_robin(player_ids: List[int]) -> List[Tuple[int, int]]:
|
||||||
|
"""Generate all round-robin pairings"""
|
||||||
|
pairings = []
|
||||||
|
n = len(player_ids)
|
||||||
|
players = list(player_ids)
|
||||||
|
if n % 2 == 1:
|
||||||
|
players.append(None) # bye
|
||||||
|
|
||||||
|
for round_num in range(len(players) - 1):
|
||||||
|
for i in range(len(players) // 2):
|
||||||
|
p1 = players[i]
|
||||||
|
p2 = players[len(players) - 1 - i]
|
||||||
|
if p1 is not None and p2 is not None:
|
||||||
|
pairings.append((p1, p2))
|
||||||
|
# Rotate: keep first fixed, rotate the rest
|
||||||
|
players = [players[0]] + [players[-1]] + players[1:-1]
|
||||||
|
|
||||||
|
return pairings
|
||||||
|
|
||||||
|
|
||||||
|
def generate_single_elim_bracket(player_ids: List[int]) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Generate single elimination bracket matches.
|
||||||
|
Returns list of match dicts with round, match_number, player1, player2, next_winner_match_id
|
||||||
|
"""
|
||||||
|
n = len(player_ids)
|
||||||
|
# Pad to power of 2
|
||||||
|
bracket_size = 2 ** math.ceil(math.log2(max(n, 2)))
|
||||||
|
players = list(player_ids) + [None] * (bracket_size - n)
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
match_id_counter = 1
|
||||||
|
|
||||||
|
# First round
|
||||||
|
round1_matches = []
|
||||||
|
for i in range(0, len(players), 2):
|
||||||
|
match = {
|
||||||
|
"temp_id": match_id_counter,
|
||||||
|
"round_number": 1,
|
||||||
|
"match_number": i // 2 + 1,
|
||||||
|
"player1_id": players[i],
|
||||||
|
"player2_id": players[i + 1],
|
||||||
|
"next_winner_temp_id": None,
|
||||||
|
"bracket_position": "winners",
|
||||||
|
"status": "scheduled",
|
||||||
|
}
|
||||||
|
# Handle byes
|
||||||
|
if players[i] is None or players[i + 1] is None:
|
||||||
|
match["status"] = "bye"
|
||||||
|
match["winner_temp"] = players[i] if players[i] is not None else players[i + 1]
|
||||||
|
round1_matches.append(match)
|
||||||
|
matches.append(match)
|
||||||
|
match_id_counter += 1
|
||||||
|
|
||||||
|
# Subsequent rounds
|
||||||
|
current_round_matches = round1_matches
|
||||||
|
round_num = 2
|
||||||
|
while len(current_round_matches) > 1:
|
||||||
|
next_round_matches = []
|
||||||
|
for i in range(0, len(current_round_matches), 2):
|
||||||
|
match = {
|
||||||
|
"temp_id": match_id_counter,
|
||||||
|
"round_number": round_num,
|
||||||
|
"match_number": i // 2 + 1,
|
||||||
|
"player1_id": None,
|
||||||
|
"player2_id": None,
|
||||||
|
"next_winner_temp_id": None,
|
||||||
|
"bracket_position": "winners",
|
||||||
|
"status": "scheduled",
|
||||||
|
}
|
||||||
|
# Set next_winner_temp_id for previous round matches
|
||||||
|
current_round_matches[i]["next_winner_temp_id"] = match_id_counter
|
||||||
|
if i + 1 < len(current_round_matches):
|
||||||
|
current_round_matches[i + 1]["next_winner_temp_id"] = match_id_counter
|
||||||
|
next_round_matches.append(match)
|
||||||
|
matches.append(match)
|
||||||
|
match_id_counter += 1
|
||||||
|
current_round_matches = next_round_matches
|
||||||
|
round_num += 1
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def generate_open_schedule(player_ids: List[int], rounds: int, courts: int) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Generate open match schedule - round robin up to 'rounds' rounds.
|
||||||
|
"""
|
||||||
|
all_pairings = generate_round_robin(player_ids)
|
||||||
|
matches = []
|
||||||
|
match_num = 1
|
||||||
|
round_num = 1
|
||||||
|
court_idx = 0
|
||||||
|
|
||||||
|
for round_round in range(min(rounds, math.ceil(len(all_pairings) / max(courts, 1)))):
|
||||||
|
start = round_round * courts
|
||||||
|
end = start + courts
|
||||||
|
round_pairings = all_pairings[start:end]
|
||||||
|
for i, (p1, p2) in enumerate(round_pairings):
|
||||||
|
matches.append({
|
||||||
|
"round_number": round_num,
|
||||||
|
"match_number": i + 1,
|
||||||
|
"player1_id": p1,
|
||||||
|
"player2_id": p2,
|
||||||
|
"status": "scheduled",
|
||||||
|
"bracket_position": "main",
|
||||||
|
})
|
||||||
|
round_num += 1
|
||||||
|
|
||||||
|
return matches
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
from pydantic_settings import BaseSettings
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
DATABASE_URL: str = "postgresql://servesync:servesync@db:5432/servesync"
|
|
||||||
REDIS_URL: str = "redis://redis:6379"
|
|
||||||
SECRET_KEY: str = "servesync-demo-secret-key-2024"
|
|
||||||
DEBUG: bool = True
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import os
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
engine = create_engine(settings.DATABASE_URL)
|
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://servesync:servesync@localhost:5432/servesync")
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from .database import engine
|
||||||
|
from .models import Base
|
||||||
|
from .routers import players, courts, events, matches, dashboard, admin
|
||||||
|
from .websocket_manager import manager
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import List, Dict
|
|
||||||
|
|
||||||
from app.database import engine, SessionLocal
|
Base.metadata.create_all(bind=engine)
|
||||||
from app.models import player, court, booking, match, tournament
|
|
||||||
|
|
||||||
# Create all tables
|
app = FastAPI(title="ServeSync API", version="2.0.0")
|
||||||
player.Base.metadata.create_all(bind=engine)
|
|
||||||
court.Base.metadata.create_all(bind=engine)
|
|
||||||
booking.Base.metadata.create_all(bind=engine)
|
|
||||||
match.Base.metadata.create_all(bind=engine)
|
|
||||||
tournament.Base.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
from app.api import players, courts, matches, tournaments, screen, admin
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="ServeSync API",
|
|
||||||
description="Pickleball Court Management System",
|
|
||||||
version="1.0.0",
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -31,112 +19,37 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Include routers
|
app.include_router(players.router)
|
||||||
app.include_router(players.router, prefix="/api")
|
app.include_router(courts.router)
|
||||||
app.include_router(courts.router, prefix="/api")
|
app.include_router(events.router)
|
||||||
app.include_router(matches.router, prefix="/api")
|
app.include_router(matches.router)
|
||||||
app.include_router(tournaments.router, prefix="/api")
|
app.include_router(dashboard.router)
|
||||||
app.include_router(screen.router, prefix="/api")
|
app.include_router(admin.router)
|
||||||
app.include_router(admin.router, prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
# WebSocket connection manager
|
@app.get("/api/health")
|
||||||
class ConnectionManager:
|
|
||||||
def __init__(self):
|
|
||||||
self.active_connections: Dict[str, List[WebSocket]] = {}
|
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, channel: str):
|
|
||||||
await websocket.accept()
|
|
||||||
if channel not in self.active_connections:
|
|
||||||
self.active_connections[channel] = []
|
|
||||||
self.active_connections[channel].append(websocket)
|
|
||||||
|
|
||||||
def disconnect(self, websocket: WebSocket, channel: str):
|
|
||||||
if channel in self.active_connections:
|
|
||||||
self.active_connections[channel].remove(websocket)
|
|
||||||
|
|
||||||
async def broadcast(self, channel: str, data: dict):
|
|
||||||
if channel in self.active_connections:
|
|
||||||
disconnected = []
|
|
||||||
for connection in self.active_connections[channel]:
|
|
||||||
try:
|
|
||||||
await connection.send_text(json.dumps(data))
|
|
||||||
except Exception:
|
|
||||||
disconnected.append(connection)
|
|
||||||
for conn in disconnected:
|
|
||||||
self.active_connections[channel].remove(conn)
|
|
||||||
|
|
||||||
|
|
||||||
manager = ConnectionManager()
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/court/{court_id}")
|
|
||||||
async def websocket_court(websocket: WebSocket, court_id: int):
|
|
||||||
"""WebSocket for real-time court display updates"""
|
|
||||||
channel = f"court_{court_id}"
|
|
||||||
await manager.connect(websocket, channel)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
# Send court data every 3 seconds
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.api.screen import get_court_display
|
|
||||||
data = get_court_display(court_id, db)
|
|
||||||
await websocket.send_text(json.dumps(data))
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
manager.disconnect(websocket, channel)
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/overview")
|
|
||||||
async def websocket_overview(websocket: WebSocket):
|
|
||||||
"""WebSocket for real-time overview display"""
|
|
||||||
channel = "overview"
|
|
||||||
await manager.connect(websocket, channel)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.api.screen import get_overview
|
|
||||||
data = get_overview(db)
|
|
||||||
await websocket.send_text(json.dumps(data))
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
manager.disconnect(websocket, channel)
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/matches")
|
|
||||||
async def websocket_matches(websocket: WebSocket):
|
|
||||||
"""WebSocket for real-time match lobby updates"""
|
|
||||||
channel = "matches"
|
|
||||||
await manager.connect(websocket, channel)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.api.matches import get_lobby
|
|
||||||
from app.api.matches import get_active_matches
|
|
||||||
data = {
|
|
||||||
"lobby": get_lobby(db),
|
|
||||||
"active": get_active_matches(db),
|
|
||||||
}
|
|
||||||
await websocket.send_text(json.dumps(data))
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
manager.disconnect(websocket, channel)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
def root():
|
|
||||||
return {"message": "ServeSync API v1.0", "status": "running"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health():
|
def health():
|
||||||
return {"status": "healthy"}
|
return {"status": "ok", "version": "2.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/dashboard")
|
||||||
|
async def ws_dashboard(websocket: WebSocket):
|
||||||
|
await manager.connect(websocket, "dashboard")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
await manager.broadcast("dashboard", {"type": "ping"})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket, "dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/screen/{court_id}")
|
||||||
|
async def ws_screen(websocket: WebSocket, court_id: int):
|
||||||
|
channel = f"screen_{court_id}"
|
||||||
|
await manager.connect(websocket, channel)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
await manager.broadcast(channel, {"type": "ping", "court_id": court_id})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket, channel)
|
||||||
|
|||||||
128
backend/app/models.py
Normal file
128
backend/app/models.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Player(Base):
|
||||||
|
__tablename__ = "players"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
contact = Column(String(100), nullable=True)
|
||||||
|
skill_level = Column(String(20), nullable=False, default="Beginner") # Beginner/Intermediate/Advanced/Elite
|
||||||
|
matches_played = Column(Integer, default=0)
|
||||||
|
wins = Column(Integer, default=0)
|
||||||
|
losses = Column(Integer, default=0)
|
||||||
|
elo = Column(Float, default=1000.0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
event_players = relationship("EventPlayer", back_populates="player")
|
||||||
|
|
||||||
|
|
||||||
|
class Court(Base):
|
||||||
|
__tablename__ = "courts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
court_type = Column(String(100), default="Sport Court")
|
||||||
|
status = Column(String(20), default="available") # available/in_use/reserved/maintenance
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
reservations = relationship("CourtReservation", back_populates="court")
|
||||||
|
matches = relationship("Match", back_populates="court")
|
||||||
|
|
||||||
|
|
||||||
|
class CourtReservation(Base):
|
||||||
|
__tablename__ = "court_reservations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
court_id = Column(Integer, ForeignKey("courts.id"), nullable=False)
|
||||||
|
player_id = Column(Integer, ForeignKey("players.id"), nullable=True)
|
||||||
|
group_name = Column(String(100), nullable=True)
|
||||||
|
start_time = Column(DateTime, nullable=False)
|
||||||
|
end_time = Column(DateTime, nullable=False)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
court = relationship("Court", back_populates="reservations")
|
||||||
|
player = relationship("Player")
|
||||||
|
|
||||||
|
|
||||||
|
class Event(Base):
|
||||||
|
__tablename__ = "events"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
format = Column(String(20), default="Singles") # Singles/Doubles
|
||||||
|
courts_count = Column(Integer, default=1)
|
||||||
|
status = Column(String(20), default="setup") # setup/active/completed
|
||||||
|
current_stage_id = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
started_at = Column(DateTime, nullable=True)
|
||||||
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
event_players = relationship("EventPlayer", back_populates="event", cascade="all, delete-orphan")
|
||||||
|
stages = relationship("Stage", back_populates="event", order_by="Stage.order", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class EventPlayer(Base):
|
||||||
|
__tablename__ = "event_players"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
|
||||||
|
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
|
||||||
|
skill_level_override = Column(String(20), nullable=True)
|
||||||
|
|
||||||
|
event = relationship("Event", back_populates="event_players")
|
||||||
|
player = relationship("Player", back_populates="event_players")
|
||||||
|
|
||||||
|
|
||||||
|
class Stage(Base):
|
||||||
|
__tablename__ = "stages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
match_type = Column(String(30), nullable=False) # Open/Skill-Based/Round Robin/Tournament
|
||||||
|
rounds = Column(Integer, default=1)
|
||||||
|
advance_count = Column(Integer, default=0) # 0 = all advance
|
||||||
|
status = Column(String(20), default="pending") # pending/active/completed
|
||||||
|
order = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
event = relationship("Event", back_populates="stages")
|
||||||
|
matches = relationship("Match", back_populates="stage", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Match(Base):
|
||||||
|
__tablename__ = "matches"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
stage_id = Column(Integer, ForeignKey("stages.id"), nullable=False)
|
||||||
|
court_id = Column(Integer, ForeignKey("courts.id"), nullable=True)
|
||||||
|
round_number = Column(Integer, default=1)
|
||||||
|
match_number = Column(Integer, default=1)
|
||||||
|
# Singles: player1_id, player2_id
|
||||||
|
# Doubles: team1_players and team2_players are JSON lists
|
||||||
|
player1_id = Column(Integer, ForeignKey("players.id"), nullable=True)
|
||||||
|
player2_id = Column(Integer, ForeignKey("players.id"), nullable=True)
|
||||||
|
team1_players = Column(JSON, nullable=True) # [player_id, player_id]
|
||||||
|
team2_players = Column(JSON, nullable=True)
|
||||||
|
score1 = Column(Integer, nullable=True)
|
||||||
|
score2 = Column(Integer, nullable=True)
|
||||||
|
winner_id = Column(Integer, ForeignKey("players.id"), nullable=True) # for singles
|
||||||
|
winner_team = Column(Integer, nullable=True) # 1 or 2 for doubles
|
||||||
|
status = Column(String(20), default="scheduled") # scheduled/in_progress/completed/bye
|
||||||
|
bracket_position = Column(String(20), nullable=True) # winners/losers
|
||||||
|
next_winner_match_id = Column(Integer, nullable=True)
|
||||||
|
next_loser_match_id = Column(Integer, nullable=True)
|
||||||
|
scheduled_time = Column(DateTime, nullable=True)
|
||||||
|
started_at = Column(DateTime, nullable=True)
|
||||||
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
stage = relationship("Stage", back_populates="matches")
|
||||||
|
court = relationship("Court", back_populates="matches")
|
||||||
|
player1 = relationship("Player", foreign_keys=[player1_id])
|
||||||
|
player2 = relationship("Player", foreign_keys=[player2_id])
|
||||||
|
winner = relationship("Player", foreign_keys=[winner_id])
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from app.models.player import Player
|
|
||||||
from app.models.court import Court
|
|
||||||
from app.models.booking import Booking
|
|
||||||
from app.models.match import Match, MatchPlayer
|
|
||||||
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from app.database import Base
|
|
||||||
import enum
|
|
||||||
|
|
||||||
|
|
||||||
class BookingStatus(str, enum.Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
CONFIRMED = "confirmed"
|
|
||||||
IN_PROGRESS = "in_progress"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class Booking(Base):
|
|
||||||
__tablename__ = "bookings"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
|
|
||||||
court_id = Column(Integer, ForeignKey("courts.id"), nullable=False)
|
|
||||||
start_time = Column(DateTime(timezone=True), nullable=False)
|
|
||||||
end_time = Column(DateTime(timezone=True), nullable=False)
|
|
||||||
duration_hours = Column(Float, default=1.0)
|
|
||||||
total_cost = Column(Float, default=0.0)
|
|
||||||
status = Column(Enum(BookingStatus), default=BookingStatus.CONFIRMED)
|
|
||||||
is_match_booking = Column(Boolean, default=False)
|
|
||||||
notes = Column(String(500))
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
player = relationship("Player", back_populates="bookings")
|
|
||||||
court = relationship("Court", back_populates="bookings")
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from app.database import Base
|
|
||||||
import enum
|
|
||||||
|
|
||||||
|
|
||||||
class Court(Base):
|
|
||||||
__tablename__ = "courts"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
name = Column(String(50), nullable=False)
|
|
||||||
court_number = Column(Integer, unique=True, nullable=False)
|
|
||||||
hourly_rate = Column(Float, default=200.0)
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
surface_type = Column(String(50), default="Sport Court")
|
|
||||||
features = Column(String(500), default="LED Lighting, Spectator Seating")
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
bookings = relationship("Booking", back_populates="court")
|
|
||||||
matches = relationship("Match", back_populates="court")
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean, JSON
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from app.database import Base
|
|
||||||
import enum
|
|
||||||
|
|
||||||
|
|
||||||
class MatchStage(str, enum.Enum):
|
|
||||||
OPEN = "open"
|
|
||||||
SKILL_BASED = "skill_based"
|
|
||||||
TOURNAMENT = "tournament"
|
|
||||||
|
|
||||||
|
|
||||||
class MatchStatus(str, enum.Enum):
|
|
||||||
LOBBY = "lobby"
|
|
||||||
IN_PROGRESS = "in_progress"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class MatchType(str, enum.Enum):
|
|
||||||
SINGLES = "singles"
|
|
||||||
DOUBLES = "doubles"
|
|
||||||
|
|
||||||
|
|
||||||
class Match(Base):
|
|
||||||
__tablename__ = "matches"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
court_id = Column(Integer, ForeignKey("courts.id"), nullable=True)
|
|
||||||
stage = Column(Enum(MatchStage), default=MatchStage.OPEN)
|
|
||||||
match_type = Column(Enum(MatchType), default=MatchType.DOUBLES)
|
|
||||||
status = Column(Enum(MatchStatus), default=MatchStatus.LOBBY)
|
|
||||||
|
|
||||||
# Scores
|
|
||||||
team1_score = Column(Integer, default=0)
|
|
||||||
team2_score = Column(Integer, default=0)
|
|
||||||
team1_games = Column(Integer, default=0)
|
|
||||||
team2_games = Column(Integer, default=0)
|
|
||||||
|
|
||||||
# Game details
|
|
||||||
max_players = Column(Integer, default=4)
|
|
||||||
min_elo = Column(Float, nullable=True)
|
|
||||||
max_elo = Column(Float, nullable=True)
|
|
||||||
|
|
||||||
# Match metadata
|
|
||||||
title = Column(String(200))
|
|
||||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
ended_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
court = relationship("Court", back_populates="matches")
|
|
||||||
match_players = relationship("MatchPlayer", back_populates="match", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
|
|
||||||
class MatchPlayer(Base):
|
|
||||||
__tablename__ = "match_players"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
match_id = Column(Integer, ForeignKey("matches.id"), nullable=False)
|
|
||||||
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
|
|
||||||
team = Column(Integer, nullable=False) # 1 or 2
|
|
||||||
elo_before = Column(Float, nullable=True)
|
|
||||||
elo_after = Column(Float, nullable=True)
|
|
||||||
elo_change = Column(Float, default=0.0)
|
|
||||||
is_winner = Column(Boolean, nullable=True)
|
|
||||||
joined_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
match = relationship("Match", back_populates="match_players")
|
|
||||||
player = relationship("Player", back_populates="match_players")
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Enum, Boolean
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from app.database import Base
|
|
||||||
import enum
|
|
||||||
|
|
||||||
|
|
||||||
class MembershipTier(str, enum.Enum):
|
|
||||||
BRONZE = "bronze"
|
|
||||||
SILVER = "silver"
|
|
||||||
GOLD = "gold"
|
|
||||||
PLATINUM = "platinum"
|
|
||||||
ELITE = "elite"
|
|
||||||
|
|
||||||
|
|
||||||
class Player(Base):
|
|
||||||
__tablename__ = "players"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
name = Column(String(100), nullable=False)
|
|
||||||
email = Column(String(255), unique=True, nullable=False)
|
|
||||||
phone = Column(String(20))
|
|
||||||
elo_rating = Column(Float, default=1000.0)
|
|
||||||
membership_tier = Column(Enum(MembershipTier), default=MembershipTier.BRONZE)
|
|
||||||
wins = Column(Integer, default=0)
|
|
||||||
losses = Column(Integer, default=0)
|
|
||||||
total_matches = Column(Integer, default=0)
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
avatar_color = Column(String(7), default="#3B82F6")
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
bookings = relationship("Booking", back_populates="player")
|
|
||||||
match_players = relationship("MatchPlayer", back_populates="player")
|
|
||||||
tournament_entries = relationship("TournamentEntry", back_populates="player")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def win_rate(self):
|
|
||||||
if self.total_matches == 0:
|
|
||||||
return 0
|
|
||||||
return round(self.wins / self.total_matches * 100, 1)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tier_label(self):
|
|
||||||
return self.membership_tier.value.capitalize()
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean, JSON
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from app.database import Base
|
|
||||||
import enum
|
|
||||||
|
|
||||||
|
|
||||||
class TournamentStatus(str, enum.Enum):
|
|
||||||
REGISTRATION = "registration"
|
|
||||||
IN_PROGRESS = "in_progress"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
|
|
||||||
|
|
||||||
class BracketType(str, enum.Enum):
|
|
||||||
WINNERS = "winners"
|
|
||||||
LOSERS = "losers"
|
|
||||||
GRAND_FINAL = "grand_final"
|
|
||||||
|
|
||||||
|
|
||||||
class TournamentMatchStatus(str, enum.Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
IN_PROGRESS = "in_progress"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
|
|
||||||
|
|
||||||
class Tournament(Base):
|
|
||||||
__tablename__ = "tournaments"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
name = Column(String(200), nullable=False)
|
|
||||||
status = Column(Enum(TournamentStatus), default=TournamentStatus.REGISTRATION)
|
|
||||||
max_participants = Column(Integer, default=8)
|
|
||||||
current_round = Column(Integer, default=1)
|
|
||||||
bracket_data = Column(JSON, nullable=True)
|
|
||||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
ended_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
entries = relationship("TournamentEntry", back_populates="tournament")
|
|
||||||
tournament_matches = relationship("TournamentMatch", back_populates="tournament")
|
|
||||||
|
|
||||||
|
|
||||||
class TournamentEntry(Base):
|
|
||||||
__tablename__ = "tournament_entries"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
tournament_id = Column(Integer, ForeignKey("tournaments.id"), nullable=False)
|
|
||||||
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
|
|
||||||
seed = Column(Integer, nullable=True)
|
|
||||||
final_rank = Column(Integer, nullable=True)
|
|
||||||
is_eliminated = Column(Boolean, default=False)
|
|
||||||
losses = Column(Integer, default=0)
|
|
||||||
wins = Column(Integer, default=0)
|
|
||||||
is_in_losers = Column(Boolean, default=False)
|
|
||||||
registered_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
tournament = relationship("Tournament", back_populates="entries")
|
|
||||||
player = relationship("Player", back_populates="tournament_entries")
|
|
||||||
|
|
||||||
|
|
||||||
class TournamentMatch(Base):
|
|
||||||
__tablename__ = "tournament_matches"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
tournament_id = Column(Integer, ForeignKey("tournaments.id"), nullable=False)
|
|
||||||
match_id = Column(Integer, ForeignKey("matches.id"), nullable=True)
|
|
||||||
round_number = Column(Integer, nullable=False)
|
|
||||||
match_number = Column(Integer, nullable=False)
|
|
||||||
bracket_type = Column(Enum(BracketType), default=BracketType.WINNERS)
|
|
||||||
status = Column(Enum(TournamentMatchStatus), default=TournamentMatchStatus.PENDING)
|
|
||||||
|
|
||||||
# Players
|
|
||||||
player1_id = Column(Integer, ForeignKey("players.id"), nullable=True)
|
|
||||||
player2_id = Column(Integer, ForeignKey("players.id"), nullable=True)
|
|
||||||
player3_id = Column(Integer, ForeignKey("players.id"), nullable=True) # doubles team2 p1
|
|
||||||
player4_id = Column(Integer, ForeignKey("players.id"), nullable=True) # doubles team2 p2
|
|
||||||
|
|
||||||
# Scores
|
|
||||||
team1_score = Column(Integer, default=0)
|
|
||||||
team2_score = Column(Integer, default=0)
|
|
||||||
winner_team = Column(Integer, nullable=True)
|
|
||||||
|
|
||||||
# Next match routing
|
|
||||||
winner_next_match = Column(Integer, nullable=True)
|
|
||||||
loser_next_match = Column(Integer, nullable=True)
|
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
tournament = relationship("Tournament", back_populates="tournament_matches")
|
|
||||||
16
backend/app/routers/admin.py
Normal file
16
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import Base
|
||||||
|
from ..database import engine
|
||||||
|
import subprocess, sys
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/seed")
|
||||||
|
def seed_data(db: Session = Depends(get_db)):
|
||||||
|
"""Re-seed all demo data"""
|
||||||
|
import subprocess, sys
|
||||||
|
result = subprocess.run([sys.executable, "/app/seed.py"], capture_output=True, text=True)
|
||||||
|
return {"ok": result.returncode == 0, "output": result.stdout[-500:], "error": result.stderr[-200:] if result.stderr else None}
|
||||||
81
backend/app/routers/courts.py
Normal file
81
backend/app/routers/courts.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import Court, CourtReservation
|
||||||
|
from ..schemas import CourtCreate, CourtStatusUpdate, ReservationCreate, ReservationOut, CourtOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/courts", tags=["courts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[CourtOut])
|
||||||
|
def list_courts(db: Session = Depends(get_db)):
|
||||||
|
courts = db.query(Court).options(joinedload(Court.reservations).joinedload(CourtReservation.player)).all()
|
||||||
|
return courts
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=CourtOut)
|
||||||
|
def create_court(data: CourtCreate, db: Session = Depends(get_db)):
|
||||||
|
court = Court(**data.model_dump())
|
||||||
|
db.add(court)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(court)
|
||||||
|
return court
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{court_id}", response_model=CourtOut)
|
||||||
|
def get_court(court_id: int, db: Session = Depends(get_db)):
|
||||||
|
court = db.query(Court).options(joinedload(Court.reservations).joinedload(CourtReservation.player)).filter(Court.id == court_id).first()
|
||||||
|
if not court:
|
||||||
|
raise HTTPException(status_code=404, detail="Court not found")
|
||||||
|
return court
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{court_id}/status")
|
||||||
|
def update_court_status(court_id: int, data: CourtStatusUpdate, db: Session = Depends(get_db)):
|
||||||
|
court = db.query(Court).filter(Court.id == court_id).first()
|
||||||
|
if not court:
|
||||||
|
raise HTTPException(status_code=404, detail="Court not found")
|
||||||
|
valid_statuses = ["available", "in_use", "reserved", "maintenance"]
|
||||||
|
if data.status not in valid_statuses:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Status must be one of {valid_statuses}")
|
||||||
|
court.status = data.status
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "status": data.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{court_id}/reservations", response_model=List[ReservationOut])
|
||||||
|
def list_reservations(court_id: int, db: Session = Depends(get_db)):
|
||||||
|
return db.query(CourtReservation).filter(CourtReservation.court_id == court_id).order_by(CourtReservation.start_time).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{court_id}/reservations", response_model=ReservationOut)
|
||||||
|
def create_reservation(court_id: int, data: ReservationCreate, db: Session = Depends(get_db)):
|
||||||
|
court = db.query(Court).filter(Court.id == court_id).first()
|
||||||
|
if not court:
|
||||||
|
raise HTTPException(status_code=404, detail="Court not found")
|
||||||
|
reservation = CourtReservation(court_id=court_id, **data.model_dump())
|
||||||
|
db.add(reservation)
|
||||||
|
# Update court status to reserved if it's currently available
|
||||||
|
if court.status == "available":
|
||||||
|
court.status = "reserved"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(reservation)
|
||||||
|
return reservation
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/reservations/{reservation_id}")
|
||||||
|
def delete_reservation(reservation_id: int, db: Session = Depends(get_db)):
|
||||||
|
reservation = db.query(CourtReservation).filter(CourtReservation.id == reservation_id).first()
|
||||||
|
if not reservation:
|
||||||
|
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||||
|
court = db.query(Court).filter(Court.id == reservation.court_id).first()
|
||||||
|
db.delete(reservation)
|
||||||
|
db.commit()
|
||||||
|
# Check if court has any remaining reservations
|
||||||
|
remaining = db.query(CourtReservation).filter(CourtReservation.court_id == reservation.court_id).count()
|
||||||
|
if remaining == 0 and court and court.status == "reserved":
|
||||||
|
court.status = "available"
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
136
backend/app/routers/dashboard.py
Normal file
136
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import Court, Match, Event, Stage, Player, CourtReservation
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def get_dashboard(db: Session = Depends(get_db)):
|
||||||
|
courts = db.query(Court).all()
|
||||||
|
courts_data = []
|
||||||
|
|
||||||
|
for court in courts:
|
||||||
|
current_match = db.query(Match).filter(
|
||||||
|
Match.court_id == court.id,
|
||||||
|
Match.status == "in_progress"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
match_data = None
|
||||||
|
elapsed = None
|
||||||
|
if current_match:
|
||||||
|
p1 = db.query(Player).filter(Player.id == current_match.player1_id).first() if current_match.player1_id else None
|
||||||
|
p2 = db.query(Player).filter(Player.id == current_match.player2_id).first() if current_match.player2_id else None
|
||||||
|
if current_match.started_at:
|
||||||
|
elapsed = int((datetime.utcnow() - current_match.started_at).total_seconds())
|
||||||
|
match_data = {
|
||||||
|
"id": current_match.id,
|
||||||
|
"player1_id": current_match.player1_id,
|
||||||
|
"player2_id": current_match.player2_id,
|
||||||
|
"player1_name": p1.name if p1 else None,
|
||||||
|
"player2_name": p2.name if p2 else None,
|
||||||
|
"score1": current_match.score1,
|
||||||
|
"score2": current_match.score2,
|
||||||
|
"status": current_match.status,
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
"stage_id": current_match.stage_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Next reservation
|
||||||
|
now = datetime.utcnow()
|
||||||
|
next_reservation = db.query(CourtReservation).filter(
|
||||||
|
CourtReservation.court_id == court.id,
|
||||||
|
CourtReservation.start_time > now
|
||||||
|
).order_by(CourtReservation.start_time).first()
|
||||||
|
|
||||||
|
courts_data.append({
|
||||||
|
"id": court.id,
|
||||||
|
"name": court.name,
|
||||||
|
"court_type": court.court_type,
|
||||||
|
"status": court.status,
|
||||||
|
"current_match": match_data,
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
"next_reservation": {
|
||||||
|
"start_time": next_reservation.start_time,
|
||||||
|
"group_name": next_reservation.group_name,
|
||||||
|
} if next_reservation else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Upcoming scheduled matches (not yet in progress)
|
||||||
|
upcoming = db.query(Match).filter(
|
||||||
|
Match.status == "scheduled",
|
||||||
|
Match.player1_id.isnot(None),
|
||||||
|
Match.player2_id.isnot(None)
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
upcoming_data = []
|
||||||
|
for m in upcoming:
|
||||||
|
p1 = db.query(Player).filter(Player.id == m.player1_id).first()
|
||||||
|
p2 = db.query(Player).filter(Player.id == m.player2_id).first()
|
||||||
|
stage = db.query(Stage).filter(Stage.id == m.stage_id).first()
|
||||||
|
event = db.query(Event).filter(Event.id == stage.event_id).first() if stage else None
|
||||||
|
upcoming_data.append({
|
||||||
|
"id": m.id,
|
||||||
|
"player1_name": p1.name if p1 else None,
|
||||||
|
"player2_name": p2.name if p2 else None,
|
||||||
|
"round_number": m.round_number,
|
||||||
|
"event_name": event.name if event else None,
|
||||||
|
"stage_name": stage.name if stage else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Active event summary
|
||||||
|
active_event = db.query(Event).filter(Event.status == "active").first()
|
||||||
|
active_event_data = None
|
||||||
|
if active_event:
|
||||||
|
active_stage = None
|
||||||
|
if active_event.current_stage_id:
|
||||||
|
active_stage = db.query(Stage).filter(Stage.id == active_event.current_stage_id).first()
|
||||||
|
active_event_data = {
|
||||||
|
"id": active_event.id,
|
||||||
|
"name": active_event.name,
|
||||||
|
"format": active_event.format,
|
||||||
|
"current_stage": active_stage.name if active_stage else None,
|
||||||
|
"current_stage_id": active_event.current_stage_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"courts": courts_data,
|
||||||
|
"upcoming_matches": upcoming_data,
|
||||||
|
"active_event": active_event_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/screen/{court_id}")
|
||||||
|
def screen_view(court_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""TV/screen display for a specific court"""
|
||||||
|
court = db.query(Court).filter(Court.id == court_id).first()
|
||||||
|
if not court:
|
||||||
|
return {"error": "Court not found"}
|
||||||
|
|
||||||
|
current_match = db.query(Match).filter(
|
||||||
|
Match.court_id == court_id,
|
||||||
|
Match.status == "in_progress"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
match_data = None
|
||||||
|
if current_match:
|
||||||
|
p1 = db.query(Player).filter(Player.id == current_match.player1_id).first()
|
||||||
|
p2 = db.query(Player).filter(Player.id == current_match.player2_id).first()
|
||||||
|
elapsed = None
|
||||||
|
if current_match.started_at:
|
||||||
|
elapsed = int((datetime.utcnow() - current_match.started_at).total_seconds())
|
||||||
|
match_data = {
|
||||||
|
"id": current_match.id,
|
||||||
|
"player1_name": p1.name if p1 else "TBD",
|
||||||
|
"player2_name": p2.name if p2 else "TBD",
|
||||||
|
"score1": current_match.score1 if current_match.score1 is not None else 0,
|
||||||
|
"score2": current_match.score2 if current_match.score2 is not None else 0,
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"court": {"id": court.id, "name": court.name, "status": court.status},
|
||||||
|
"current_match": match_data,
|
||||||
|
}
|
||||||
294
backend/app/routers/events.py
Normal file
294
backend/app/routers/events.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import Event, EventPlayer, Stage, Match, Player, Court
|
||||||
|
from ..schemas import (
|
||||||
|
EventCreate, EventUpdate, EventPlayerAdd, EventPlayerOut,
|
||||||
|
StageCreate, StageUpdate, StageOut, MatchOut
|
||||||
|
)
|
||||||
|
from ..bracket import calculate_stages, generate_single_elim_bracket, generate_open_schedule, generate_round_robin
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/events", tags=["events"])
|
||||||
|
|
||||||
|
|
||||||
|
def _event_with_details(event_id: int, db: Session):
|
||||||
|
return db.query(Event).options(
|
||||||
|
joinedload(Event.event_players).joinedload(EventPlayer.player),
|
||||||
|
joinedload(Event.stages).joinedload(Stage.matches)
|
||||||
|
).filter(Event.id == event_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def list_events(db: Session = Depends(get_db)):
|
||||||
|
events = db.query(Event).order_by(Event.created_at.desc()).all()
|
||||||
|
return [{"id": e.id, "name": e.name, "format": e.format, "status": e.status,
|
||||||
|
"courts_count": e.courts_count, "created_at": e.created_at,
|
||||||
|
"player_count": len(e.event_players), "stage_count": len(e.stages)} for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
def create_event(data: EventCreate, db: Session = Depends(get_db)):
|
||||||
|
event = Event(**data.model_dump())
|
||||||
|
db.add(event)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(event)
|
||||||
|
return {"id": event.id, "name": event.name, "format": event.format,
|
||||||
|
"status": event.status, "courts_count": event.courts_count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calculate")
|
||||||
|
def calculate_event(players: int, format: str = "Singles", courts: int = 1):
|
||||||
|
return calculate_stages(players, format, courts)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{event_id}")
|
||||||
|
def get_event(event_id: int, db: Session = Depends(get_db)):
|
||||||
|
event = _event_with_details(event_id, db)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
stages_data = []
|
||||||
|
for stage in event.stages:
|
||||||
|
matches_data = []
|
||||||
|
for m in stage.matches:
|
||||||
|
p1 = db.query(Player).filter(Player.id == m.player1_id).first() if m.player1_id else None
|
||||||
|
p2 = db.query(Player).filter(Player.id == m.player2_id).first() if m.player2_id else None
|
||||||
|
court = db.query(Court).filter(Court.id == m.court_id).first() if m.court_id else None
|
||||||
|
matches_data.append({
|
||||||
|
"id": m.id, "round_number": m.round_number, "match_number": m.match_number,
|
||||||
|
"player1_id": m.player1_id, "player2_id": m.player2_id,
|
||||||
|
"team1_players": m.team1_players, "team2_players": m.team2_players,
|
||||||
|
"player1_name": p1.name if p1 else None,
|
||||||
|
"player2_name": p2.name if p2 else None,
|
||||||
|
"score1": m.score1, "score2": m.score2,
|
||||||
|
"winner_id": m.winner_id, "winner_team": m.winner_team,
|
||||||
|
"status": m.status, "bracket_position": m.bracket_position,
|
||||||
|
"next_winner_match_id": m.next_winner_match_id,
|
||||||
|
"next_loser_match_id": m.next_loser_match_id,
|
||||||
|
"court_id": m.court_id,
|
||||||
|
"court_name": court.name if court else None,
|
||||||
|
"started_at": m.started_at, "completed_at": m.completed_at,
|
||||||
|
})
|
||||||
|
stages_data.append({
|
||||||
|
"id": stage.id, "name": stage.name, "match_type": stage.match_type,
|
||||||
|
"rounds": stage.rounds, "advance_count": stage.advance_count,
|
||||||
|
"status": stage.status, "order": stage.order,
|
||||||
|
"matches": matches_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
players_data = [{
|
||||||
|
"id": ep.id, "player_id": ep.player_id,
|
||||||
|
"skill_level_override": ep.skill_level_override,
|
||||||
|
"player": {"id": ep.player.id, "name": ep.player.name,
|
||||||
|
"skill_level": ep.player.skill_level, "elo": ep.player.elo,
|
||||||
|
"matches_played": ep.player.matches_played,
|
||||||
|
"wins": ep.player.wins, "losses": ep.player.losses}
|
||||||
|
} for ep in event.event_players]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": event.id, "name": event.name, "format": event.format,
|
||||||
|
"courts_count": event.courts_count, "status": event.status,
|
||||||
|
"current_stage_id": event.current_stage_id,
|
||||||
|
"created_at": event.created_at, "started_at": event.started_at,
|
||||||
|
"players": players_data,
|
||||||
|
"stages": stages_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{event_id}")
|
||||||
|
def update_event(event_id: int, data: EventUpdate, db: Session = Depends(get_db)):
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
for k, v in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(event, k, v)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_id}/players")
|
||||||
|
def add_player_to_event(event_id: int, data: EventPlayerAdd, db: Session = Depends(get_db)):
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
player = db.query(Player).filter(Player.id == data.player_id).first()
|
||||||
|
if not player:
|
||||||
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
existing = db.query(EventPlayer).filter(
|
||||||
|
EventPlayer.event_id == event_id, EventPlayer.player_id == data.player_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Player already in event")
|
||||||
|
ep = EventPlayer(event_id=event_id, player_id=data.player_id,
|
||||||
|
skill_level_override=data.skill_level_override)
|
||||||
|
db.add(ep)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ep)
|
||||||
|
return {"id": ep.id, "player_id": ep.player_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{event_id}/players/{player_id}")
|
||||||
|
def remove_player_from_event(event_id: int, player_id: int, db: Session = Depends(get_db)):
|
||||||
|
ep = db.query(EventPlayer).filter(
|
||||||
|
EventPlayer.event_id == event_id, EventPlayer.player_id == player_id
|
||||||
|
).first()
|
||||||
|
if not ep:
|
||||||
|
raise HTTPException(status_code=404, detail="Player not in event")
|
||||||
|
db.delete(ep)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_id}/stages")
|
||||||
|
def add_stage(event_id: int, data: StageCreate, db: Session = Depends(get_db)):
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
stage = Stage(event_id=event_id, **data.model_dump())
|
||||||
|
db.add(stage)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(stage)
|
||||||
|
return {"id": stage.id, "name": stage.name, "match_type": stage.match_type,
|
||||||
|
"rounds": stage.rounds, "advance_count": stage.advance_count, "status": stage.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{event_id}/stages/{stage_id}")
|
||||||
|
def update_stage(event_id: int, stage_id: int, data: StageUpdate, db: Session = Depends(get_db)):
|
||||||
|
stage = db.query(Stage).filter(Stage.id == stage_id, Stage.event_id == event_id).first()
|
||||||
|
if not stage:
|
||||||
|
raise HTTPException(status_code=404, detail="Stage not found")
|
||||||
|
for k, v in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(stage, k, v)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_id}/stages/{stage_id}/generate")
|
||||||
|
def generate_stage_matches(event_id: int, stage_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Generate matches for a stage"""
|
||||||
|
stage = db.query(Stage).filter(Stage.id == stage_id, Stage.event_id == event_id).first()
|
||||||
|
if not stage:
|
||||||
|
raise HTTPException(status_code=404, detail="Stage not found")
|
||||||
|
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
|
||||||
|
# Delete existing matches for this stage
|
||||||
|
db.query(Match).filter(Match.stage_id == stage_id).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Get players for this event
|
||||||
|
event_players = db.query(EventPlayer).filter(EventPlayer.event_id == event_id).all()
|
||||||
|
player_ids = [ep.player_id for ep in event_players]
|
||||||
|
|
||||||
|
if len(player_ids) < 2:
|
||||||
|
raise HTTPException(status_code=400, detail="Need at least 2 players")
|
||||||
|
|
||||||
|
match_type = stage.match_type
|
||||||
|
matches_to_create = []
|
||||||
|
|
||||||
|
if match_type in ["Open", "Open Match"]:
|
||||||
|
matches_raw = generate_open_schedule(player_ids, stage.rounds, event.courts_count)
|
||||||
|
for m in matches_raw:
|
||||||
|
matches_to_create.append(Match(
|
||||||
|
stage_id=stage_id,
|
||||||
|
round_number=m["round_number"],
|
||||||
|
match_number=m["match_number"],
|
||||||
|
player1_id=m["player1_id"],
|
||||||
|
player2_id=m["player2_id"],
|
||||||
|
status=m["status"],
|
||||||
|
bracket_position=m.get("bracket_position", "main"),
|
||||||
|
))
|
||||||
|
|
||||||
|
elif match_type == "Round Robin":
|
||||||
|
pairings = generate_round_robin(player_ids)
|
||||||
|
for i, (p1, p2) in enumerate(pairings):
|
||||||
|
matches_to_create.append(Match(
|
||||||
|
stage_id=stage_id,
|
||||||
|
round_number=1,
|
||||||
|
match_number=i + 1,
|
||||||
|
player1_id=p1,
|
||||||
|
player2_id=p2,
|
||||||
|
status="scheduled",
|
||||||
|
bracket_position="main",
|
||||||
|
))
|
||||||
|
|
||||||
|
elif match_type in ["Tournament", "Double Elimination"]:
|
||||||
|
bracket_matches = generate_single_elim_bracket(player_ids)
|
||||||
|
# Create matches and track temp IDs
|
||||||
|
created = {}
|
||||||
|
# First pass - create all match objects
|
||||||
|
db_matches = []
|
||||||
|
for bm in bracket_matches:
|
||||||
|
m = Match(
|
||||||
|
stage_id=stage_id,
|
||||||
|
round_number=bm["round_number"],
|
||||||
|
match_number=bm["match_number"],
|
||||||
|
player1_id=bm.get("player1_id"),
|
||||||
|
player2_id=bm.get("player2_id"),
|
||||||
|
status=bm["status"],
|
||||||
|
bracket_position=bm.get("bracket_position", "winners"),
|
||||||
|
)
|
||||||
|
db.add(m)
|
||||||
|
db.flush()
|
||||||
|
created[bm["temp_id"]] = m
|
||||||
|
db_matches.append((bm, m))
|
||||||
|
|
||||||
|
# Second pass - link next_winner_match_id
|
||||||
|
for bm, m in db_matches:
|
||||||
|
if bm.get("next_winner_temp_id") and bm["next_winner_temp_id"] in created:
|
||||||
|
m.next_winner_match_id = created[bm["next_winner_temp_id"]].id
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "match_count": len(bracket_matches)}
|
||||||
|
|
||||||
|
elif match_type == "Skill-Based":
|
||||||
|
# Group players by skill level and create matches within same skill groups
|
||||||
|
players = db.query(Player).filter(Player.id.in_(player_ids)).all()
|
||||||
|
skill_order = {"Beginner": 0, "Intermediate": 1, "Advanced": 2, "Elite": 3}
|
||||||
|
players_sorted = sorted(players, key=lambda p: skill_order.get(p.skill_level, 0))
|
||||||
|
sorted_ids = [p.id for p in players_sorted]
|
||||||
|
pairings = generate_round_robin(sorted_ids)[:len(sorted_ids)]
|
||||||
|
for i, (p1, p2) in enumerate(pairings):
|
||||||
|
matches_to_create.append(Match(
|
||||||
|
stage_id=stage_id,
|
||||||
|
round_number=1,
|
||||||
|
match_number=i + 1,
|
||||||
|
player1_id=p1,
|
||||||
|
player2_id=p2,
|
||||||
|
status="scheduled",
|
||||||
|
bracket_position="main",
|
||||||
|
))
|
||||||
|
|
||||||
|
for m in matches_to_create:
|
||||||
|
db.add(m)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "match_count": len(matches_to_create)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_id}/start")
|
||||||
|
def start_event(event_id: int, db: Session = Depends(get_db)):
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
event.status = "active"
|
||||||
|
event.started_at = datetime.utcnow()
|
||||||
|
# Activate first stage
|
||||||
|
first_stage = db.query(Stage).filter(Stage.event_id == event_id).order_by(Stage.order).first()
|
||||||
|
if first_stage:
|
||||||
|
first_stage.status = "active"
|
||||||
|
event.current_stage_id = first_stage.id
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_id}/complete")
|
||||||
|
def complete_event(event_id: int, db: Session = Depends(get_db)):
|
||||||
|
event = db.query(Event).filter(Event.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
event.status = "completed"
|
||||||
|
event.completed_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
156
backend/app/routers/matches.py
Normal file
156
backend/app/routers/matches.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import Match, Player, Court, Stage
|
||||||
|
from ..schemas import MatchScoreUpdate, MatchCourtAssign
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/matches", tags=["matches"])
|
||||||
|
|
||||||
|
|
||||||
|
def _match_detail(match: Match, db: Session) -> dict:
|
||||||
|
p1 = db.query(Player).filter(Player.id == match.player1_id).first() if match.player1_id else None
|
||||||
|
p2 = db.query(Player).filter(Player.id == match.player2_id).first() if match.player2_id else None
|
||||||
|
court = db.query(Court).filter(Court.id == match.court_id).first() if match.court_id else None
|
||||||
|
return {
|
||||||
|
"id": match.id,
|
||||||
|
"stage_id": match.stage_id,
|
||||||
|
"court_id": match.court_id,
|
||||||
|
"court_name": court.name if court else None,
|
||||||
|
"round_number": match.round_number,
|
||||||
|
"match_number": match.match_number,
|
||||||
|
"player1_id": match.player1_id,
|
||||||
|
"player2_id": match.player2_id,
|
||||||
|
"player1_name": p1.name if p1 else None,
|
||||||
|
"player2_name": p2.name if p2 else None,
|
||||||
|
"team1_players": match.team1_players,
|
||||||
|
"team2_players": match.team2_players,
|
||||||
|
"score1": match.score1,
|
||||||
|
"score2": match.score2,
|
||||||
|
"winner_id": match.winner_id,
|
||||||
|
"winner_team": match.winner_team,
|
||||||
|
"status": match.status,
|
||||||
|
"bracket_position": match.bracket_position,
|
||||||
|
"next_winner_match_id": match.next_winner_match_id,
|
||||||
|
"next_loser_match_id": match.next_loser_match_id,
|
||||||
|
"started_at": match.started_at,
|
||||||
|
"completed_at": match.completed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{match_id}")
|
||||||
|
def get_match(match_id: int, db: Session = Depends(get_db)):
|
||||||
|
match = db.query(Match).filter(Match.id == match_id).first()
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(status_code=404, detail="Match not found")
|
||||||
|
return _match_detail(match, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{match_id}/start")
|
||||||
|
def start_match(match_id: int, db: Session = Depends(get_db)):
|
||||||
|
match = db.query(Match).filter(Match.id == match_id).first()
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(status_code=404, detail="Match not found")
|
||||||
|
|
||||||
|
match.status = "in_progress"
|
||||||
|
match.started_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Auto-assign available court if none assigned
|
||||||
|
if not match.court_id:
|
||||||
|
stage = db.query(Stage).filter(Stage.id == match.stage_id).first()
|
||||||
|
if stage:
|
||||||
|
# Find available court
|
||||||
|
in_use_court_ids = db.query(Match.court_id).filter(
|
||||||
|
Match.status == "in_progress", Match.court_id.isnot(None)
|
||||||
|
).all()
|
||||||
|
in_use_ids = [r[0] for r in in_use_court_ids]
|
||||||
|
available_court = db.query(Court).filter(
|
||||||
|
Court.status.in_(["available", "reserved"]),
|
||||||
|
Court.id.notin_(in_use_ids)
|
||||||
|
).first()
|
||||||
|
if available_court:
|
||||||
|
match.court_id = available_court.id
|
||||||
|
available_court.status = "in_use"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return _match_detail(match, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{match_id}/score")
|
||||||
|
def enter_score(match_id: int, data: MatchScoreUpdate, db: Session = Depends(get_db)):
|
||||||
|
match = db.query(Match).filter(Match.id == match_id).first()
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(status_code=404, detail="Match not found")
|
||||||
|
|
||||||
|
match.score1 = data.score1
|
||||||
|
match.score2 = data.score2
|
||||||
|
match.status = "completed"
|
||||||
|
match.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Determine winner
|
||||||
|
if data.score1 > data.score2:
|
||||||
|
match.winner_id = match.player1_id
|
||||||
|
match.winner_team = 1
|
||||||
|
winner_id = match.player1_id
|
||||||
|
loser_id = match.player2_id
|
||||||
|
else:
|
||||||
|
match.winner_id = match.player2_id
|
||||||
|
match.winner_team = 2
|
||||||
|
winner_id = match.player2_id
|
||||||
|
loser_id = match.player1_id
|
||||||
|
|
||||||
|
# Free up court
|
||||||
|
if match.court_id:
|
||||||
|
court = db.query(Court).filter(Court.id == match.court_id).first()
|
||||||
|
if court:
|
||||||
|
court.status = "available"
|
||||||
|
|
||||||
|
# Update player stats
|
||||||
|
if winner_id:
|
||||||
|
winner = db.query(Player).filter(Player.id == winner_id).first()
|
||||||
|
if winner:
|
||||||
|
winner.wins += 1
|
||||||
|
winner.matches_played += 1
|
||||||
|
winner.elo += 20 # Simple ELO gain
|
||||||
|
if loser_id:
|
||||||
|
loser = db.query(Player).filter(Player.id == loser_id).first()
|
||||||
|
if loser:
|
||||||
|
loser.losses += 1
|
||||||
|
loser.matches_played += 1
|
||||||
|
loser.elo = max(800, loser.elo - 15)
|
||||||
|
|
||||||
|
# Advance bracket: place winner into next match
|
||||||
|
if match.next_winner_match_id:
|
||||||
|
next_match = db.query(Match).filter(Match.id == match.next_winner_match_id).first()
|
||||||
|
if next_match:
|
||||||
|
if next_match.player1_id is None:
|
||||||
|
next_match.player1_id = winner_id
|
||||||
|
elif next_match.player2_id is None:
|
||||||
|
next_match.player2_id = winner_id
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return _match_detail(match, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{match_id}/court")
|
||||||
|
def assign_court(match_id: int, data: MatchCourtAssign, db: Session = Depends(get_db)):
|
||||||
|
match = db.query(Match).filter(Match.id == match_id).first()
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(status_code=404, detail="Match not found")
|
||||||
|
|
||||||
|
# Free old court
|
||||||
|
if match.court_id:
|
||||||
|
old_court = db.query(Court).filter(Court.id == match.court_id).first()
|
||||||
|
if old_court and old_court.status == "in_use":
|
||||||
|
old_court.status = "available"
|
||||||
|
|
||||||
|
match.court_id = data.court_id
|
||||||
|
|
||||||
|
# Mark new court in use if match is in progress
|
||||||
|
if data.court_id and match.status == "in_progress":
|
||||||
|
new_court = db.query(Court).filter(Court.id == data.court_id).first()
|
||||||
|
if new_court:
|
||||||
|
new_court.status = "in_use"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
52
backend/app/routers/players.py
Normal file
52
backend/app/routers/players.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import Player
|
||||||
|
from ..schemas import PlayerCreate, PlayerUpdate, PlayerOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/players", tags=["players"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[PlayerOut])
|
||||||
|
def list_players(db: Session = Depends(get_db)):
|
||||||
|
return db.query(Player).order_by(Player.elo.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=PlayerOut)
|
||||||
|
def create_player(data: PlayerCreate, db: Session = Depends(get_db)):
|
||||||
|
player = Player(**data.model_dump())
|
||||||
|
db.add(player)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(player)
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{player_id}", response_model=PlayerOut)
|
||||||
|
def get_player(player_id: int, db: Session = Depends(get_db)):
|
||||||
|
player = db.query(Player).filter(Player.id == player_id).first()
|
||||||
|
if not player:
|
||||||
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{player_id}", response_model=PlayerOut)
|
||||||
|
def update_player(player_id: int, data: PlayerUpdate, db: Session = Depends(get_db)):
|
||||||
|
player = db.query(Player).filter(Player.id == player_id).first()
|
||||||
|
if not player:
|
||||||
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
for k, v in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(player, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(player)
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{player_id}")
|
||||||
|
def delete_player(player_id: int, db: Session = Depends(get_db)):
|
||||||
|
player = db.query(Player).filter(Player.id == player_id).first()
|
||||||
|
if not player:
|
||||||
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
db.delete(player)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
163
backend/app/schemas.py
Normal file
163
backend/app/schemas.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# Player schemas
|
||||||
|
class PlayerCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
contact: Optional[str] = None
|
||||||
|
skill_level: str = "Beginner"
|
||||||
|
|
||||||
|
class PlayerUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
contact: Optional[str] = None
|
||||||
|
skill_level: Optional[str] = None
|
||||||
|
|
||||||
|
class PlayerOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
contact: Optional[str]
|
||||||
|
skill_level: str
|
||||||
|
matches_played: int
|
||||||
|
wins: int
|
||||||
|
losses: int
|
||||||
|
elo: float
|
||||||
|
created_at: datetime
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Court schemas
|
||||||
|
class CourtCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
court_type: str = "Sport Court"
|
||||||
|
|
||||||
|
class CourtStatusUpdate(BaseModel):
|
||||||
|
status: str # available/in_use/reserved/maintenance
|
||||||
|
|
||||||
|
class ReservationCreate(BaseModel):
|
||||||
|
player_id: Optional[int] = None
|
||||||
|
group_name: Optional[str] = None
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
class ReservationOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
court_id: int
|
||||||
|
player_id: Optional[int]
|
||||||
|
group_name: Optional[str]
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
notes: Optional[str]
|
||||||
|
player: Optional[PlayerOut] = None
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class CourtOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
court_type: str
|
||||||
|
status: str
|
||||||
|
reservations: List[ReservationOut] = []
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Event schemas
|
||||||
|
class EventCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
format: str = "Singles"
|
||||||
|
courts_count: int = 1
|
||||||
|
|
||||||
|
class EventUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
format: Optional[str] = None
|
||||||
|
courts_count: Optional[int] = None
|
||||||
|
|
||||||
|
class EventPlayerAdd(BaseModel):
|
||||||
|
player_id: int
|
||||||
|
skill_level_override: Optional[str] = None
|
||||||
|
|
||||||
|
class EventPlayerOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
player_id: int
|
||||||
|
skill_level_override: Optional[str]
|
||||||
|
player: PlayerOut
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Stage schemas
|
||||||
|
class StageCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
match_type: str # Open/Skill-Based/Round Robin/Tournament
|
||||||
|
rounds: int = 1
|
||||||
|
advance_count: int = 0
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
class StageUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
match_type: Optional[str] = None
|
||||||
|
rounds: Optional[int] = None
|
||||||
|
advance_count: Optional[int] = None
|
||||||
|
|
||||||
|
class StageOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
event_id: int
|
||||||
|
name: str
|
||||||
|
match_type: str
|
||||||
|
rounds: int
|
||||||
|
advance_count: int
|
||||||
|
status: str
|
||||||
|
order: int
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Match schemas
|
||||||
|
class MatchScoreUpdate(BaseModel):
|
||||||
|
score1: int
|
||||||
|
score2: int
|
||||||
|
|
||||||
|
class MatchCourtAssign(BaseModel):
|
||||||
|
court_id: Optional[int]
|
||||||
|
|
||||||
|
class MatchOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
stage_id: int
|
||||||
|
court_id: Optional[int]
|
||||||
|
round_number: int
|
||||||
|
match_number: int
|
||||||
|
player1_id: Optional[int]
|
||||||
|
player2_id: Optional[int]
|
||||||
|
team1_players: Optional[List[int]]
|
||||||
|
team2_players: Optional[List[int]]
|
||||||
|
score1: Optional[int]
|
||||||
|
score2: Optional[int]
|
||||||
|
winner_id: Optional[int]
|
||||||
|
winner_team: Optional[int]
|
||||||
|
status: str
|
||||||
|
bracket_position: Optional[str]
|
||||||
|
next_winner_match_id: Optional[int]
|
||||||
|
next_loser_match_id: Optional[int]
|
||||||
|
scheduled_time: Optional[datetime]
|
||||||
|
started_at: Optional[datetime]
|
||||||
|
completed_at: Optional[datetime]
|
||||||
|
player1: Optional[PlayerOut] = None
|
||||||
|
player2: Optional[PlayerOut] = None
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Dashboard schema
|
||||||
|
class DashboardCourtData(BaseModel):
|
||||||
|
court: CourtOut
|
||||||
|
current_match: Optional[MatchOut] = None
|
||||||
|
elapsed_seconds: Optional[int] = None
|
||||||
|
|
||||||
|
class DashboardData(BaseModel):
|
||||||
|
courts: List[DashboardCourtData]
|
||||||
|
upcoming_matches: List[MatchOut]
|
||||||
|
active_event: Optional[dict] = None
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""ELO Rating System for ServeSync"""
|
|
||||||
|
|
||||||
K_FACTOR = 32 # Standard K-factor
|
|
||||||
|
|
||||||
|
|
||||||
def expected_score(rating_a: float, rating_b: float) -> float:
|
|
||||||
"""Calculate expected score for player A vs player B"""
|
|
||||||
return 1 / (1 + 10 ** ((rating_b - rating_a) / 400))
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_elo_change(winner_rating: float, loser_rating: float) -> tuple[float, float]:
|
|
||||||
"""
|
|
||||||
Calculate ELO changes after a match.
|
|
||||||
Returns (winner_change, loser_change)
|
|
||||||
"""
|
|
||||||
expected_winner = expected_score(winner_rating, loser_rating)
|
|
||||||
expected_loser = expected_score(loser_rating, winner_rating)
|
|
||||||
|
|
||||||
winner_change = K_FACTOR * (1 - expected_winner)
|
|
||||||
loser_change = K_FACTOR * (0 - expected_loser)
|
|
||||||
|
|
||||||
return round(winner_change, 2), round(loser_change, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_team_elo(player1_rating: float, player2_rating: float = None) -> float:
|
|
||||||
"""Calculate team ELO (average for doubles)"""
|
|
||||||
if player2_rating is None:
|
|
||||||
return player1_rating
|
|
||||||
return (player1_rating + player2_rating) / 2
|
|
||||||
|
|
||||||
|
|
||||||
def get_tier_from_elo(elo: float) -> str:
|
|
||||||
"""Get membership tier based on ELO rating"""
|
|
||||||
if elo < 1000:
|
|
||||||
return "bronze"
|
|
||||||
elif elo < 1200:
|
|
||||||
return "silver"
|
|
||||||
elif elo < 1500:
|
|
||||||
return "gold"
|
|
||||||
elif elo < 1800:
|
|
||||||
return "platinum"
|
|
||||||
else:
|
|
||||||
return "elite"
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_match(player_elo: float, match_min_elo: float, match_max_elo: float) -> bool:
|
|
||||||
"""Check if player is within ELO range for skill-based match"""
|
|
||||||
return match_min_elo <= player_elo <= match_max_elo
|
|
||||||
|
|
||||||
|
|
||||||
def get_elo_range(player_elo: float, tolerance: float = 200) -> tuple[float, float]:
|
|
||||||
"""Get valid ELO range for matching"""
|
|
||||||
return (max(0, player_elo - tolerance), player_elo + tolerance)
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
"""Matchmaking Engine for ServeSync"""
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
|
|
||||||
from app.models.court import Court
|
|
||||||
from app.models.player import Player
|
|
||||||
from app.models.booking import Booking, BookingStatus
|
|
||||||
from app.services.elo import calculate_elo_change, get_elo_range, is_valid_match
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, List
|
|
||||||
|
|
||||||
|
|
||||||
def create_open_match(db: Session, title: str, match_type: MatchType = MatchType.DOUBLES) -> Match:
|
|
||||||
"""Stage 1: Create an open match lobby - no restrictions"""
|
|
||||||
max_players = 4 if match_type == MatchType.DOUBLES else 2
|
|
||||||
match = Match(
|
|
||||||
stage=MatchStage.OPEN,
|
|
||||||
match_type=match_type,
|
|
||||||
status=MatchStatus.LOBBY,
|
|
||||||
title=title,
|
|
||||||
max_players=max_players,
|
|
||||||
)
|
|
||||||
db.add(match)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(match)
|
|
||||||
return match
|
|
||||||
|
|
||||||
|
|
||||||
def create_skill_match(
|
|
||||||
db: Session,
|
|
||||||
title: str,
|
|
||||||
creator_elo: float,
|
|
||||||
match_type: MatchType = MatchType.DOUBLES,
|
|
||||||
tolerance: float = 200,
|
|
||||||
) -> Match:
|
|
||||||
"""Stage 2: Create a skill-based match with ELO constraints"""
|
|
||||||
min_elo, max_elo = get_elo_range(creator_elo, tolerance)
|
|
||||||
max_players = 4 if match_type == MatchType.DOUBLES else 2
|
|
||||||
match = Match(
|
|
||||||
stage=MatchStage.SKILL_BASED,
|
|
||||||
match_type=match_type,
|
|
||||||
status=MatchStatus.LOBBY,
|
|
||||||
title=title,
|
|
||||||
max_players=max_players,
|
|
||||||
min_elo=min_elo,
|
|
||||||
max_elo=max_elo,
|
|
||||||
)
|
|
||||||
db.add(match)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(match)
|
|
||||||
return match
|
|
||||||
|
|
||||||
|
|
||||||
def join_match(db: Session, match_id: int, player_id: int, team: int) -> Optional[MatchPlayer]:
|
|
||||||
"""Add a player to a match lobby"""
|
|
||||||
match = db.query(Match).filter(Match.id == match_id).first()
|
|
||||||
if not match or match.status != MatchStatus.LOBBY:
|
|
||||||
return None
|
|
||||||
|
|
||||||
player = db.query(Player).filter(Player.id == player_id).first()
|
|
||||||
if not player:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check player count
|
|
||||||
current_players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).count()
|
|
||||||
if current_players >= match.max_players:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if already joined
|
|
||||||
existing = db.query(MatchPlayer).filter(
|
|
||||||
MatchPlayer.match_id == match_id,
|
|
||||||
MatchPlayer.player_id == player_id
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
# For skill-based matches, check ELO constraint
|
|
||||||
if match.stage == MatchStage.SKILL_BASED:
|
|
||||||
if match.min_elo and match.max_elo:
|
|
||||||
if not is_valid_match(player.elo_rating, match.min_elo, match.max_elo):
|
|
||||||
return None
|
|
||||||
|
|
||||||
mp = MatchPlayer(
|
|
||||||
match_id=match_id,
|
|
||||||
player_id=player_id,
|
|
||||||
team=team,
|
|
||||||
elo_before=player.elo_rating,
|
|
||||||
)
|
|
||||||
db.add(mp)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(mp)
|
|
||||||
return mp
|
|
||||||
|
|
||||||
|
|
||||||
def start_match(db: Session, match_id: int) -> Optional[Match]:
|
|
||||||
"""Start a match - auto-assign court if available"""
|
|
||||||
match = db.query(Match).filter(Match.id == match_id).first()
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
|
|
||||||
players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).count()
|
|
||||||
if players < match.max_players:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Auto-assign an available court
|
|
||||||
if not match.court_id:
|
|
||||||
court = find_available_court(db)
|
|
||||||
if court:
|
|
||||||
match.court_id = court.id
|
|
||||||
# Create booking
|
|
||||||
now = datetime.utcnow()
|
|
||||||
booking = Booking(
|
|
||||||
player_id=db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).first().player_id,
|
|
||||||
court_id=court.id,
|
|
||||||
start_time=now,
|
|
||||||
end_time=now + timedelta(hours=1),
|
|
||||||
duration_hours=1.0,
|
|
||||||
total_cost=court.hourly_rate,
|
|
||||||
status=BookingStatus.IN_PROGRESS,
|
|
||||||
is_match_booking=True,
|
|
||||||
)
|
|
||||||
db.add(booking)
|
|
||||||
|
|
||||||
match.status = MatchStatus.IN_PROGRESS
|
|
||||||
match.started_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(match)
|
|
||||||
return match
|
|
||||||
|
|
||||||
|
|
||||||
def find_available_court(db: Session) -> Optional[Court]:
|
|
||||||
"""Find an available court not currently in use"""
|
|
||||||
from sqlalchemy import and_
|
|
||||||
busy_court_ids = db.query(Match.court_id).filter(
|
|
||||||
Match.status == MatchStatus.IN_PROGRESS,
|
|
||||||
Match.court_id.isnot(None)
|
|
||||||
).all()
|
|
||||||
busy_ids = [c[0] for c in busy_court_ids]
|
|
||||||
|
|
||||||
court = db.query(Court).filter(
|
|
||||||
Court.is_active == True,
|
|
||||||
Court.id.notin_(busy_ids)
|
|
||||||
).first()
|
|
||||||
return court
|
|
||||||
|
|
||||||
|
|
||||||
def complete_match(db: Session, match_id: int, team1_score: int, team2_score: int) -> Optional[Match]:
|
|
||||||
"""Complete a match and update ELO ratings"""
|
|
||||||
match = db.query(Match).filter(Match.id == match_id).first()
|
|
||||||
if not match or match.status != MatchStatus.IN_PROGRESS:
|
|
||||||
return None
|
|
||||||
|
|
||||||
match.status = MatchStatus.COMPLETED
|
|
||||||
match.team1_score = team1_score
|
|
||||||
match.team2_score = team2_score
|
|
||||||
match.ended_at = datetime.utcnow()
|
|
||||||
|
|
||||||
winner_team = 1 if team1_score > team2_score else 2
|
|
||||||
|
|
||||||
# Get players
|
|
||||||
team1_players = db.query(MatchPlayer).filter(
|
|
||||||
MatchPlayer.match_id == match_id, MatchPlayer.team == 1
|
|
||||||
).all()
|
|
||||||
team2_players = db.query(MatchPlayer).filter(
|
|
||||||
MatchPlayer.match_id == match_id, MatchPlayer.team == 2
|
|
||||||
).all()
|
|
||||||
|
|
||||||
# Calculate average ELO
|
|
||||||
team1_avg = sum(p.elo_before or p.player.elo_rating for p in team1_players) / len(team1_players)
|
|
||||||
team2_avg = sum(p.elo_before or p.player.elo_rating for p in team2_players) / len(team2_players)
|
|
||||||
|
|
||||||
if winner_team == 1:
|
|
||||||
w_change, l_change = calculate_elo_change(team1_avg, team2_avg)
|
|
||||||
else:
|
|
||||||
l_change, w_change = calculate_elo_change(team2_avg, team1_avg)
|
|
||||||
w_change, l_change = -l_change, -w_change
|
|
||||||
|
|
||||||
# Update player ELOs
|
|
||||||
for mp in team1_players:
|
|
||||||
is_winner = (winner_team == 1)
|
|
||||||
change = w_change if is_winner else l_change
|
|
||||||
mp.is_winner = is_winner
|
|
||||||
mp.elo_change = change
|
|
||||||
mp.elo_after = (mp.elo_before or mp.player.elo_rating) + change
|
|
||||||
mp.player.elo_rating += change
|
|
||||||
if is_winner:
|
|
||||||
mp.player.wins += 1
|
|
||||||
else:
|
|
||||||
mp.player.losses += 1
|
|
||||||
mp.player.total_matches += 1
|
|
||||||
|
|
||||||
for mp in team2_players:
|
|
||||||
is_winner = (winner_team == 2)
|
|
||||||
change = w_change if is_winner else l_change
|
|
||||||
mp.is_winner = is_winner
|
|
||||||
mp.elo_change = change
|
|
||||||
mp.elo_after = (mp.elo_before or mp.player.elo_rating) + change
|
|
||||||
mp.player.elo_rating += change
|
|
||||||
if is_winner:
|
|
||||||
mp.player.wins += 1
|
|
||||||
else:
|
|
||||||
mp.player.losses += 1
|
|
||||||
mp.player.total_matches += 1
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(match)
|
|
||||||
return match
|
|
||||||
|
|
||||||
|
|
||||||
def get_lobby_matches(db: Session, stage: Optional[MatchStage] = None) -> List[Match]:
|
|
||||||
"""Get all lobby matches, optionally filtered by stage"""
|
|
||||||
query = db.query(Match).filter(Match.status == MatchStatus.LOBBY)
|
|
||||||
if stage:
|
|
||||||
query = query.filter(Match.stage == stage)
|
|
||||||
return query.order_by(Match.created_at.desc()).all()
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
"""Double Elimination Tournament Engine for ServeSync"""
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus, BracketType, TournamentMatchStatus
|
|
||||||
from app.models.player import Player
|
|
||||||
from app.models.court import Court
|
|
||||||
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
|
|
||||||
from app.services.matchmaking import find_available_court
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, List
|
|
||||||
import math
|
|
||||||
|
|
||||||
|
|
||||||
def create_tournament(db: Session, name: str, max_participants: int = 8) -> Tournament:
|
|
||||||
"""Create a new tournament"""
|
|
||||||
t = Tournament(name=name, max_participants=max_participants)
|
|
||||||
db.add(t)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(t)
|
|
||||||
return t
|
|
||||||
|
|
||||||
|
|
||||||
def register_player(db: Session, tournament_id: int, player_id: int) -> Optional[TournamentEntry]:
|
|
||||||
"""Register a player for the tournament"""
|
|
||||||
t = db.query(Tournament).filter(Tournament.id == tournament_id).first()
|
|
||||||
if not t or t.status != TournamentStatus.REGISTRATION:
|
|
||||||
return None
|
|
||||||
|
|
||||||
current_count = db.query(TournamentEntry).filter(TournamentEntry.tournament_id == tournament_id).count()
|
|
||||||
if current_count >= t.max_participants:
|
|
||||||
return None
|
|
||||||
|
|
||||||
existing = db.query(TournamentEntry).filter(
|
|
||||||
TournamentEntry.tournament_id == tournament_id,
|
|
||||||
TournamentEntry.player_id == player_id
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
entry = TournamentEntry(tournament_id=tournament_id, player_id=player_id)
|
|
||||||
db.add(entry)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(entry)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def start_tournament(db: Session, tournament_id: int) -> Optional[Tournament]:
|
|
||||||
"""Start tournament and generate initial bracket"""
|
|
||||||
t = db.query(Tournament).filter(Tournament.id == tournament_id).first()
|
|
||||||
if not t or t.status != TournamentStatus.REGISTRATION:
|
|
||||||
return None
|
|
||||||
|
|
||||||
entries = db.query(TournamentEntry).filter(
|
|
||||||
TournamentEntry.tournament_id == tournament_id
|
|
||||||
).join(Player).order_by(Player.elo_rating.desc()).all()
|
|
||||||
|
|
||||||
if len(entries) < 4:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Seed players by ELO
|
|
||||||
for i, entry in enumerate(entries):
|
|
||||||
entry.seed = i + 1
|
|
||||||
|
|
||||||
# Generate bracket matches
|
|
||||||
_generate_winners_bracket(db, t, entries)
|
|
||||||
|
|
||||||
t.status = TournamentStatus.IN_PROGRESS
|
|
||||||
t.started_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(t)
|
|
||||||
return t
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_winners_bracket(db: Session, tournament: Tournament, entries: List[TournamentEntry]):
|
|
||||||
"""Generate the initial winners bracket matches"""
|
|
||||||
n = len(entries)
|
|
||||||
# Pair up by seeding (1 vs n, 2 vs n-1, etc.)
|
|
||||||
match_num = 1
|
|
||||||
pairs = []
|
|
||||||
for i in range(n // 2):
|
|
||||||
p1 = entries[i]
|
|
||||||
p2 = entries[n - 1 - i]
|
|
||||||
pairs.append((p1, p2))
|
|
||||||
|
|
||||||
for p1, p2 in pairs:
|
|
||||||
tm = TournamentMatch(
|
|
||||||
tournament_id=tournament.id,
|
|
||||||
round_number=1,
|
|
||||||
match_number=match_num,
|
|
||||||
bracket_type=BracketType.WINNERS,
|
|
||||||
status=TournamentMatchStatus.PENDING,
|
|
||||||
player1_id=p1.player_id,
|
|
||||||
player2_id=p2.player_id,
|
|
||||||
)
|
|
||||||
db.add(tm)
|
|
||||||
match_num += 1
|
|
||||||
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def complete_tournament_match(
|
|
||||||
db: Session,
|
|
||||||
tournament_match_id: int,
|
|
||||||
team1_score: int,
|
|
||||||
team2_score: int
|
|
||||||
) -> Optional[TournamentMatch]:
|
|
||||||
"""Complete a tournament match and advance bracket"""
|
|
||||||
tm = db.query(TournamentMatch).filter(TournamentMatch.id == tournament_match_id).first()
|
|
||||||
if not tm:
|
|
||||||
return None
|
|
||||||
|
|
||||||
tm.team1_score = team1_score
|
|
||||||
tm.team2_score = team2_score
|
|
||||||
tm.winner_team = 1 if team1_score > team2_score else 2
|
|
||||||
tm.status = TournamentMatchStatus.COMPLETED
|
|
||||||
tm.completed_at = datetime.utcnow()
|
|
||||||
|
|
||||||
winner_player_id = tm.player1_id if tm.winner_team == 1 else tm.player2_id
|
|
||||||
loser_player_id = tm.player2_id if tm.winner_team == 1 else tm.player1_id
|
|
||||||
|
|
||||||
# Update entry stats
|
|
||||||
winner_entry = db.query(TournamentEntry).filter(
|
|
||||||
TournamentEntry.tournament_id == tm.tournament_id,
|
|
||||||
TournamentEntry.player_id == winner_player_id
|
|
||||||
).first()
|
|
||||||
loser_entry = db.query(TournamentEntry).filter(
|
|
||||||
TournamentEntry.tournament_id == tm.tournament_id,
|
|
||||||
TournamentEntry.player_id == loser_player_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if winner_entry:
|
|
||||||
winner_entry.wins += 1
|
|
||||||
if loser_entry:
|
|
||||||
loser_entry.losses += 1
|
|
||||||
|
|
||||||
# Double elimination logic
|
|
||||||
if loser_entry and loser_entry.losses < 2:
|
|
||||||
# Move to losers bracket
|
|
||||||
loser_entry.is_in_losers = True
|
|
||||||
_create_losers_match(db, tm, loser_player_id)
|
|
||||||
elif loser_entry:
|
|
||||||
# Eliminated
|
|
||||||
loser_entry.is_eliminated = True
|
|
||||||
_assign_final_ranks(db, tm.tournament_id)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(tm)
|
|
||||||
return tm
|
|
||||||
|
|
||||||
|
|
||||||
def _create_losers_match(db: Session, completed_match: TournamentMatch, loser_id: int):
|
|
||||||
"""Route loser to losers bracket"""
|
|
||||||
# Find existing pending losers match in next round
|
|
||||||
next_round = completed_match.round_number + 1
|
|
||||||
existing = db.query(TournamentMatch).filter(
|
|
||||||
TournamentMatch.tournament_id == completed_match.tournament_id,
|
|
||||||
TournamentMatch.bracket_type == BracketType.LOSERS,
|
|
||||||
TournamentMatch.round_number == next_round,
|
|
||||||
TournamentMatch.player2_id.is_(None),
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
existing.player2_id = loser_id
|
|
||||||
else:
|
|
||||||
# Create a new losers match
|
|
||||||
match_count = db.query(TournamentMatch).filter(
|
|
||||||
TournamentMatch.tournament_id == completed_match.tournament_id,
|
|
||||||
TournamentMatch.bracket_type == BracketType.LOSERS,
|
|
||||||
TournamentMatch.round_number == next_round,
|
|
||||||
).count()
|
|
||||||
|
|
||||||
tm = TournamentMatch(
|
|
||||||
tournament_id=completed_match.tournament_id,
|
|
||||||
round_number=next_round,
|
|
||||||
match_number=match_count + 1,
|
|
||||||
bracket_type=BracketType.LOSERS,
|
|
||||||
status=TournamentMatchStatus.PENDING,
|
|
||||||
player1_id=loser_id,
|
|
||||||
)
|
|
||||||
db.add(tm)
|
|
||||||
|
|
||||||
|
|
||||||
def _assign_final_ranks(db: Session, tournament_id: int):
|
|
||||||
"""Assign final ranks to eliminated players"""
|
|
||||||
entries = db.query(TournamentEntry).filter(
|
|
||||||
TournamentEntry.tournament_id == tournament_id,
|
|
||||||
TournamentEntry.is_eliminated == True,
|
|
||||||
TournamentEntry.final_rank.is_(None)
|
|
||||||
).order_by(TournamentEntry.losses.desc()).all()
|
|
||||||
|
|
||||||
remaining = db.query(TournamentEntry).filter(
|
|
||||||
TournamentEntry.tournament_id == tournament_id,
|
|
||||||
TournamentEntry.is_eliminated == False
|
|
||||||
).count()
|
|
||||||
|
|
||||||
base_rank = remaining + 1
|
|
||||||
for i, entry in enumerate(entries):
|
|
||||||
entry.final_rank = base_rank + i
|
|
||||||
|
|
||||||
|
|
||||||
def get_bracket_data(db: Session, tournament_id: int) -> dict:
|
|
||||||
"""Get structured bracket data for frontend visualization"""
|
|
||||||
tournament = db.query(Tournament).filter(Tournament.id == tournament_id).first()
|
|
||||||
if not tournament:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
matches = db.query(TournamentMatch).filter(
|
|
||||||
TournamentMatch.tournament_id == tournament_id
|
|
||||||
).order_by(TournamentMatch.round_number, TournamentMatch.match_number).all()
|
|
||||||
|
|
||||||
entries = db.query(TournamentEntry).filter(
|
|
||||||
TournamentEntry.tournament_id == tournament_id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
players_map = {}
|
|
||||||
for entry in entries:
|
|
||||||
player = db.query(Player).filter(Player.id == entry.player_id).first()
|
|
||||||
if player:
|
|
||||||
players_map[player.id] = {
|
|
||||||
"id": player.id,
|
|
||||||
"name": player.name,
|
|
||||||
"elo": player.elo_rating,
|
|
||||||
"seed": entry.seed,
|
|
||||||
"wins": entry.wins,
|
|
||||||
"losses": entry.losses,
|
|
||||||
"is_eliminated": entry.is_eliminated,
|
|
||||||
"is_in_losers": entry.is_in_losers,
|
|
||||||
"final_rank": entry.final_rank,
|
|
||||||
}
|
|
||||||
|
|
||||||
winners_bracket = []
|
|
||||||
losers_bracket = []
|
|
||||||
grand_final = []
|
|
||||||
|
|
||||||
for m in matches:
|
|
||||||
match_data = {
|
|
||||||
"id": m.id,
|
|
||||||
"round": m.round_number,
|
|
||||||
"match_number": m.match_number,
|
|
||||||
"status": m.status.value,
|
|
||||||
"player1": players_map.get(m.player1_id),
|
|
||||||
"player2": players_map.get(m.player2_id),
|
|
||||||
"team1_score": m.team1_score,
|
|
||||||
"team2_score": m.team2_score,
|
|
||||||
"winner_team": m.winner_team,
|
|
||||||
}
|
|
||||||
if m.bracket_type == BracketType.WINNERS:
|
|
||||||
winners_bracket.append(match_data)
|
|
||||||
elif m.bracket_type == BracketType.LOSERS:
|
|
||||||
losers_bracket.append(match_data)
|
|
||||||
else:
|
|
||||||
grand_final.append(match_data)
|
|
||||||
|
|
||||||
# Build leaderboard
|
|
||||||
leaderboard = sorted(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"player": players_map[e.player_id],
|
|
||||||
"wins": e.wins,
|
|
||||||
"losses": e.losses,
|
|
||||||
"is_eliminated": e.is_eliminated,
|
|
||||||
"is_in_losers": e.is_in_losers,
|
|
||||||
"final_rank": e.final_rank,
|
|
||||||
}
|
|
||||||
for e in entries
|
|
||||||
if e.player_id in players_map
|
|
||||||
],
|
|
||||||
key=lambda x: (x["final_rank"] or 999, -x["wins"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tournament": {
|
|
||||||
"id": tournament.id,
|
|
||||||
"name": tournament.name,
|
|
||||||
"status": tournament.status.value,
|
|
||||||
"current_round": tournament.current_round,
|
|
||||||
},
|
|
||||||
"winners_bracket": winners_bracket,
|
|
||||||
"losers_bracket": losers_bracket,
|
|
||||||
"grand_final": grand_final,
|
|
||||||
"leaderboard": leaderboard,
|
|
||||||
}
|
|
||||||
40
backend/app/websocket_manager.py
Normal file
40
backend/app/websocket_manager.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from typing import Dict, List
|
||||||
|
from fastapi import WebSocket
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.active_connections: Dict[str, List[WebSocket]] = {}
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket, channel: str):
|
||||||
|
await websocket.accept()
|
||||||
|
if channel not in self.active_connections:
|
||||||
|
self.active_connections[channel] = []
|
||||||
|
self.active_connections[channel].append(websocket)
|
||||||
|
|
||||||
|
def disconnect(self, websocket: WebSocket, channel: str):
|
||||||
|
if channel in self.active_connections:
|
||||||
|
try:
|
||||||
|
self.active_connections[channel].remove(websocket)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def broadcast(self, channel: str, data: dict):
|
||||||
|
if channel not in self.active_connections:
|
||||||
|
return
|
||||||
|
dead = []
|
||||||
|
for ws in self.active_connections[channel]:
|
||||||
|
try:
|
||||||
|
await ws.send_json(data)
|
||||||
|
except Exception:
|
||||||
|
dead.append(ws)
|
||||||
|
for ws in dead:
|
||||||
|
self.disconnect(ws, channel)
|
||||||
|
|
||||||
|
async def broadcast_all(self, data: dict):
|
||||||
|
for channel in list(self.active_connections.keys()):
|
||||||
|
await self.broadcast(channel, data)
|
||||||
|
|
||||||
|
|
||||||
|
manager = ConnectionManager()
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
fastapi==0.111.0
|
fastapi==0.109.0
|
||||||
uvicorn[standard]==0.29.0
|
uvicorn[standard]==0.27.0
|
||||||
sqlalchemy==2.0.30
|
sqlalchemy==2.0.25
|
||||||
alembic==1.13.1
|
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
redis==5.0.4
|
redis==5.0.1
|
||||||
arq==0.25.0
|
|
||||||
websockets==12.0
|
websockets==12.0
|
||||||
python-dotenv==1.0.1
|
python-multipart==0.0.7
|
||||||
pydantic==2.7.1
|
pydantic==2.6.0
|
||||||
pydantic-settings==2.2.1
|
alembic==1.13.1
|
||||||
httpx==0.27.0
|
httpx==0.26.0
|
||||||
python-multipart==0.0.9
|
arq==0.25.0
|
||||||
|
|||||||
702
backend/seed.py
702
backend/seed.py
@@ -1,439 +1,325 @@
|
|||||||
"""Seed demo data for ServeSync"""
|
"""
|
||||||
import sys
|
Seed data for ServeSync Demo
|
||||||
|
- 8 players with varied skills
|
||||||
|
- 4 courts
|
||||||
|
- 1 completed event (Spring Singles Open)
|
||||||
|
- 1 in-progress event (Summer Tournament - Round 2)
|
||||||
|
- Sample court reservations
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.database import engine, SessionLocal
|
|
||||||
from app.models.player import Player, MembershipTier
|
|
||||||
from app.models.court import Court
|
|
||||||
from app.models.booking import Booking, BookingStatus
|
|
||||||
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
|
|
||||||
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus, BracketType, TournamentMatchStatus
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import random
|
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql://servesync:servesync@db:5432/servesync")
|
||||||
|
|
||||||
def clear_data(db: Session):
|
from app.database import engine, SessionLocal
|
||||||
db.query(TournamentMatch).delete()
|
from app.models import Base, Player, Court, CourtReservation, Event, EventPlayer, Stage, Match
|
||||||
db.query(TournamentEntry).delete()
|
|
||||||
db.query(Tournament).delete()
|
|
||||||
db.query(MatchPlayer).delete()
|
|
||||||
db.query(Match).delete()
|
|
||||||
db.query(Booking).delete()
|
|
||||||
db.query(Court).delete()
|
|
||||||
db.query(Player).delete()
|
|
||||||
db.commit()
|
|
||||||
# Reset sequences
|
|
||||||
for table in ['players', 'courts', 'bookings', 'matches', 'match_players',
|
|
||||||
'tournaments', 'tournament_entries', 'tournament_matches']:
|
|
||||||
try:
|
|
||||||
db.execute(__import__('sqlalchemy').text(f"ALTER SEQUENCE {table}_id_seq RESTART WITH 1"))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
db.commit()
|
|
||||||
print("Cleared existing data")
|
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
def seed_players(db: Session) -> list:
|
# Clear existing data
|
||||||
players_data = [
|
print("Clearing existing data...")
|
||||||
{"name": "Marco Santos", "email": "marco@demo.com", "elo": 1850, "tier": MembershipTier.ELITE, "color": "#EF4444", "wins": 45, "losses": 8},
|
db.query(Match).delete()
|
||||||
{"name": "Sofia Reyes", "email": "sofia@demo.com", "elo": 1720, "tier": MembershipTier.ELITE, "color": "#EC4899", "wins": 38, "losses": 12},
|
db.query(Stage).delete()
|
||||||
{"name": "Carlos Mendez", "email": "carlos@demo.com", "elo": 1630, "tier": MembershipTier.PLATINUM, "color": "#8B5CF6", "wins": 32, "losses": 15},
|
db.query(EventPlayer).delete()
|
||||||
{"name": "Ana Cruz", "email": "ana@demo.com", "elo": 1580, "tier": MembershipTier.PLATINUM, "color": "#06B6D4", "wins": 28, "losses": 18},
|
db.query(Event).delete()
|
||||||
{"name": "Juan Dela Cruz", "email": "juan@demo.com", "elo": 1450, "tier": MembershipTier.GOLD, "color": "#F59E0B", "wins": 22, "losses": 20},
|
db.query(CourtReservation).delete()
|
||||||
{"name": "Maria Garcia", "email": "maria@demo.com", "elo": 1380, "tier": MembershipTier.GOLD, "color": "#10B981", "wins": 18, "losses": 22},
|
db.query(Court).delete()
|
||||||
{"name": "Pedro Lopez", "email": "pedro@demo.com", "elo": 1250, "tier": MembershipTier.SILVER, "color": "#3B82F6", "wins": 14, "losses": 19},
|
db.query(Player).delete()
|
||||||
{"name": "Rosa Martinez", "email": "rosa@demo.com", "elo": 1190, "tier": MembershipTier.SILVER, "color": "#F97316", "wins": 11, "losses": 21},
|
db.commit()
|
||||||
{"name": "Diego Torres", "email": "diego@demo.com", "elo": 1100, "tier": MembershipTier.SILVER, "color": "#84CC16", "wins": 9, "losses": 18},
|
|
||||||
{"name": "Lucia Flores", "email": "lucia@demo.com", "elo": 980, "tier": MembershipTier.BRONZE, "color": "#14B8A6", "wins": 6, "losses": 20},
|
|
||||||
{"name": "Miguel Ortega", "email": "miguel@demo.com", "elo": 920, "tier": MembershipTier.BRONZE, "color": "#6366F1", "wins": 4, "losses": 15},
|
|
||||||
{"name": "Isabella Vega", "email": "isabella@demo.com", "elo": 850, "tier": MembershipTier.BRONZE, "color": "#D946EF", "wins": 2, "losses": 12},
|
|
||||||
]
|
|
||||||
|
|
||||||
players = []
|
# ─── PLAYERS ────────────────────────────────────────────────────────────────
|
||||||
for pd in players_data:
|
print("Creating players...")
|
||||||
p = Player(
|
players_data = [
|
||||||
name=pd["name"],
|
{"name": "Marco Reyes", "contact": "marco@example.com", "skill_level": "Elite", "elo": 1852, "matches_played": 48, "wins": 38, "losses": 10},
|
||||||
email=pd["email"],
|
{"name": "Ana Santos", "contact": "ana@example.com", "skill_level": "Advanced", "elo": 1645, "matches_played": 35, "wins": 24, "losses": 11},
|
||||||
elo_rating=pd["elo"],
|
{"name": "Carlos Tan", "contact": "carlos@example.com", "skill_level": "Advanced", "elo": 1583, "matches_played": 30, "wins": 20, "losses": 10},
|
||||||
membership_tier=pd["tier"],
|
{"name": "Diana Flores", "contact": "diana@example.com", "skill_level": "Intermediate", "elo": 1402, "matches_played": 22, "wins": 13, "losses": 9},
|
||||||
avatar_color=pd["color"],
|
{"name": "Tony Garcia", "contact": "tony@example.com", "skill_level": "Intermediate", "elo": 1281, "matches_played": 18, "wins": 9, "losses": 9},
|
||||||
wins=pd["wins"],
|
{"name": "Lisa Cruz", "contact": "lisa@example.com", "skill_level": "Intermediate", "elo": 1318, "matches_played": 15, "wins": 8, "losses": 7},
|
||||||
losses=pd["losses"],
|
{"name": "Jake Torres", "contact": "jake@example.com", "skill_level": "Beginner", "elo": 883, "matches_played": 8, "wins": 3, "losses": 5},
|
||||||
total_matches=pd["wins"] + pd["losses"],
|
{"name": "Maria Lim", "contact": "maria@example.com", "skill_level": "Beginner", "elo": 947, "matches_played": 10, "wins": 4, "losses": 6},
|
||||||
)
|
]
|
||||||
|
|
||||||
|
players = []
|
||||||
|
for pd in players_data:
|
||||||
|
p = Player(**pd)
|
||||||
db.add(p)
|
db.add(p)
|
||||||
players.append(p)
|
players.append(p)
|
||||||
|
db.commit()
|
||||||
db.commit()
|
for p in players:
|
||||||
for p in players:
|
|
||||||
db.refresh(p)
|
db.refresh(p)
|
||||||
print(f"Created {len(players)} players")
|
print(f" Created {len(players)} players")
|
||||||
return players
|
|
||||||
|
|
||||||
|
# ─── COURTS ─────────────────────────────────────────────────────────────────
|
||||||
def seed_courts(db: Session) -> list:
|
print("Creating courts...")
|
||||||
courts_data = [
|
courts_data = [
|
||||||
{"name": "Court 1", "number": 1, "rate": 200.0, "surface": "Sport Court", "features": "LED Lighting, Pro Net System"},
|
{"name": "Court A", "court_type": "Sport Court Premium", "status": "available"},
|
||||||
{"name": "Court 2", "number": 2, "rate": 200.0, "surface": "Sport Court", "features": "LED Lighting, Spectator Seating"},
|
{"name": "Court B", "court_type": "Sport Court Premium", "status": "available"},
|
||||||
{"name": "Court 3", "number": 3, "rate": 250.0, "surface": "Cushioned", "features": "Tournament Grade, Scoreboards"},
|
{"name": "Court C", "court_type": "Cushioned Tournament Grade", "status": "in_use"},
|
||||||
{"name": "Court 4", "number": 4, "rate": 250.0, "surface": "Cushioned", "features": "Tournament Grade, VIP Lounge Access"},
|
{"name": "Court D", "court_type": "Cushioned Tournament Grade", "status": "in_use"},
|
||||||
]
|
]
|
||||||
|
courts = []
|
||||||
courts = []
|
for cd in courts_data:
|
||||||
for cd in courts_data:
|
c = Court(**cd)
|
||||||
c = Court(
|
|
||||||
name=cd["name"],
|
|
||||||
court_number=cd["number"],
|
|
||||||
hourly_rate=cd["rate"],
|
|
||||||
surface_type=cd["surface"],
|
|
||||||
features=cd["features"],
|
|
||||||
)
|
|
||||||
db.add(c)
|
db.add(c)
|
||||||
courts.append(c)
|
courts.append(c)
|
||||||
|
db.commit()
|
||||||
db.commit()
|
for c in courts:
|
||||||
for c in courts:
|
|
||||||
db.refresh(c)
|
db.refresh(c)
|
||||||
print(f"Created {len(courts)} courts")
|
print(f" Created {len(courts)} courts")
|
||||||
return courts
|
|
||||||
|
|
||||||
|
# ─── RESERVATIONS ───────────────────────────────────────────────────────────
|
||||||
|
print("Creating reservations...")
|
||||||
|
now = datetime.utcnow()
|
||||||
|
today_2pm = now.replace(hour=6, minute=0, second=0, microsecond=0) + timedelta(hours=8) # 2pm PHT = 6am UTC
|
||||||
|
today_4pm = today_2pm + timedelta(hours=2)
|
||||||
|
tomorrow_10am = (now + timedelta(days=1)).replace(hour=2, minute=0, second=0, microsecond=0)
|
||||||
|
tomorrow_12pm = tomorrow_10am + timedelta(hours=2)
|
||||||
|
|
||||||
def seed_bookings(db: Session, players: list, courts: list):
|
reservations = [
|
||||||
now = datetime.utcnow()
|
CourtReservation(court_id=courts[0].id, player_id=players[5].id,
|
||||||
bookings = []
|
group_name="Lisa Cruz Group", start_time=today_2pm, end_time=today_4pm,
|
||||||
|
notes="Training session"),
|
||||||
|
CourtReservation(court_id=courts[1].id, group_name="Maintenance Crew",
|
||||||
|
start_time=tomorrow_10am, end_time=tomorrow_12pm,
|
||||||
|
notes="Net replacement and floor inspection"),
|
||||||
|
]
|
||||||
|
for r in reservations:
|
||||||
|
db.add(r)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
# Future bookings
|
# ─── EVENT 1: SPRING SINGLES OPEN (Completed) ────────────────────────────────
|
||||||
for i in range(8):
|
print("Creating completed event...")
|
||||||
player = players[i % len(players)]
|
event1 = Event(
|
||||||
court = courts[i % len(courts)]
|
name="Spring Singles Open",
|
||||||
start = now + timedelta(hours=i + 2)
|
format="Singles",
|
||||||
end = start + timedelta(hours=1)
|
courts_count=2,
|
||||||
b = Booking(
|
status="completed",
|
||||||
player_id=player.id,
|
created_at=now - timedelta(days=7),
|
||||||
court_id=court.id,
|
started_at=now - timedelta(days=7, hours=2),
|
||||||
start_time=start,
|
completed_at=now - timedelta(days=7),
|
||||||
end_time=end,
|
)
|
||||||
duration_hours=1.0,
|
db.add(event1)
|
||||||
total_cost=court.hourly_rate,
|
db.commit()
|
||||||
status=BookingStatus.CONFIRMED,
|
db.refresh(event1)
|
||||||
)
|
|
||||||
db.add(b)
|
|
||||||
bookings.append(b)
|
|
||||||
|
|
||||||
db.commit()
|
# Add all 8 players to event1
|
||||||
print(f"Created {len(bookings)} bookings")
|
for p in players:
|
||||||
|
ep = EventPlayer(event_id=event1.id, player_id=p.id, skill_level_override=p.skill_level)
|
||||||
|
db.add(ep)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Stage 1 - Open Stage (completed)
|
||||||
|
stage1_e1 = Stage(event_id=event1.id, name="Open Stage", match_type="Open",
|
||||||
|
rounds=2, advance_count=4, status="completed", order=0)
|
||||||
|
db.add(stage1_e1)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(stage1_e1)
|
||||||
|
|
||||||
def seed_active_matches(db: Session, players: list, courts: list) -> list:
|
# Stage 2 - Tournament (completed)
|
||||||
matches = []
|
stage2_e1 = Stage(event_id=event1.id, name="Finals Tournament", match_type="Tournament",
|
||||||
|
rounds=3, advance_count=0, status="completed", order=1)
|
||||||
|
db.add(stage2_e1)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(stage2_e1)
|
||||||
|
|
||||||
# Match 1: Active doubles on Court 1 (Open match)
|
# Create completed open stage matches
|
||||||
m1 = Match(
|
open_matches = [
|
||||||
court_id=courts[0].id,
|
(players[0], players[7], 21, 8), # Marco vs Maria
|
||||||
stage=MatchStage.OPEN,
|
(players[1], players[6], 21, 14), # Ana vs Jake
|
||||||
match_type=MatchType.DOUBLES,
|
(players[2], players[5], 21, 11), # Carlos vs Lisa
|
||||||
status=MatchStatus.IN_PROGRESS,
|
(players[3], players[4], 21, 18), # Diana vs Tony
|
||||||
title="Open Doubles - Court 1",
|
# Round 2
|
||||||
max_players=4,
|
(players[0], players[6], 21, 10), # Marco vs Jake
|
||||||
team1_score=7,
|
(players[1], players[4], 21, 16), # Ana vs Tony
|
||||||
team2_score=5,
|
(players[2], players[7], 21, 9), # Carlos vs Maria
|
||||||
started_at=datetime.utcnow() - timedelta(minutes=25),
|
(players[3], players[5], 18, 21), # Diana vs Lisa (Lisa wins!)
|
||||||
)
|
]
|
||||||
db.add(m1)
|
completed_at_base = now - timedelta(days=7, hours=1, minutes=30)
|
||||||
db.flush()
|
for i, (p1, p2, s1, s2) in enumerate(open_matches):
|
||||||
|
winner = p1 if s1 > s2 else p2
|
||||||
for player_id, team in [(players[0].id, 1), (players[1].id, 1), (players[2].id, 2), (players[3].id, 2)]:
|
|
||||||
mp = MatchPlayer(match_id=m1.id, player_id=player_id, team=team, elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating)
|
|
||||||
db.add(mp)
|
|
||||||
matches.append(m1)
|
|
||||||
|
|
||||||
# Match 2: Active skill-based on Court 2
|
|
||||||
m2 = Match(
|
|
||||||
court_id=courts[1].id,
|
|
||||||
stage=MatchStage.SKILL_BASED,
|
|
||||||
match_type=MatchType.DOUBLES,
|
|
||||||
status=MatchStatus.IN_PROGRESS,
|
|
||||||
title="Skill Match - Platinum League",
|
|
||||||
max_players=4,
|
|
||||||
min_elo=1300,
|
|
||||||
max_elo=1700,
|
|
||||||
team1_score=3,
|
|
||||||
team2_score=6,
|
|
||||||
started_at=datetime.utcnow() - timedelta(minutes=15),
|
|
||||||
)
|
|
||||||
db.add(m2)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
for player_id, team in [(players[4].id, 1), (players[5].id, 1), (players[6].id, 2), (players[7].id, 2)]:
|
|
||||||
mp = MatchPlayer(match_id=m2.id, player_id=player_id, team=team, elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating)
|
|
||||||
db.add(mp)
|
|
||||||
matches.append(m2)
|
|
||||||
|
|
||||||
# Match 3: Lobby (waiting for players)
|
|
||||||
m3 = Match(
|
|
||||||
stage=MatchStage.OPEN,
|
|
||||||
match_type=MatchType.DOUBLES,
|
|
||||||
status=MatchStatus.LOBBY,
|
|
||||||
title="Evening Open Game",
|
|
||||||
max_players=4,
|
|
||||||
)
|
|
||||||
db.add(m3)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
for player_id, team in [(players[8].id, 1), (players[9].id, 2)]:
|
|
||||||
mp = MatchPlayer(match_id=m3.id, player_id=player_id, team=team, elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating)
|
|
||||||
db.add(mp)
|
|
||||||
matches.append(m3)
|
|
||||||
|
|
||||||
# Match 4: Skill-based lobby
|
|
||||||
m4 = Match(
|
|
||||||
stage=MatchStage.SKILL_BASED,
|
|
||||||
match_type=MatchType.DOUBLES,
|
|
||||||
status=MatchStatus.LOBBY,
|
|
||||||
title="Gold Tier Challenge",
|
|
||||||
max_players=4,
|
|
||||||
min_elo=1200,
|
|
||||||
max_elo=1600,
|
|
||||||
)
|
|
||||||
db.add(m4)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
mp = MatchPlayer(match_id=m4.id, player_id=players[4].id, team=1, elo_before=players[4].elo_rating)
|
|
||||||
db.add(mp)
|
|
||||||
matches.append(m4)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
for m in matches:
|
|
||||||
db.refresh(m)
|
|
||||||
print(f"Created {len(matches)} matches (2 active, 2 lobby)")
|
|
||||||
return matches
|
|
||||||
|
|
||||||
|
|
||||||
def seed_tournament(db: Session, players: list, courts: list):
|
|
||||||
"""Create a sample double elimination tournament in progress"""
|
|
||||||
t = Tournament(
|
|
||||||
name="ServeSync Grand Prix - March 2024",
|
|
||||||
status=TournamentStatus.IN_PROGRESS,
|
|
||||||
max_participants=8,
|
|
||||||
current_round=2,
|
|
||||||
started_at=datetime.utcnow() - timedelta(hours=2),
|
|
||||||
)
|
|
||||||
db.add(t)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
# Register 8 players
|
|
||||||
tournament_players = players[:8]
|
|
||||||
entries = []
|
|
||||||
for i, player in enumerate(tournament_players):
|
|
||||||
entry = TournamentEntry(
|
|
||||||
tournament_id=t.id,
|
|
||||||
player_id=player.id,
|
|
||||||
seed=i + 1,
|
|
||||||
wins=0,
|
|
||||||
losses=0,
|
|
||||||
)
|
|
||||||
db.add(entry)
|
|
||||||
entries.append(entry)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
# Round 1 Winners Bracket (4 matches) - all completed
|
|
||||||
r1_matches = [
|
|
||||||
(players[0].id, players[7].id, 11, 5), # 1 vs 8
|
|
||||||
(players[1].id, players[6].id, 11, 7), # 2 vs 7
|
|
||||||
(players[2].id, players[5].id, 8, 11), # 3 vs 6 - player 5 wins
|
|
||||||
(players[3].id, players[4].id, 11, 9), # 4 vs 5
|
|
||||||
]
|
|
||||||
|
|
||||||
winners_r1 = [] # winner player IDs after round 1
|
|
||||||
losers_r1 = [] # loser player IDs after round 1
|
|
||||||
|
|
||||||
for i, (p1_id, p2_id, s1, s2) in enumerate(r1_matches):
|
|
||||||
tm = TournamentMatch(
|
|
||||||
tournament_id=t.id,
|
|
||||||
round_number=1,
|
|
||||||
match_number=i + 1,
|
|
||||||
bracket_type=BracketType.WINNERS,
|
|
||||||
status=TournamentMatchStatus.COMPLETED,
|
|
||||||
player1_id=p1_id,
|
|
||||||
player2_id=p2_id,
|
|
||||||
team1_score=s1,
|
|
||||||
team2_score=s2,
|
|
||||||
winner_team=1 if s1 > s2 else 2,
|
|
||||||
completed_at=datetime.utcnow() - timedelta(hours=1, minutes=30),
|
|
||||||
)
|
|
||||||
db.add(tm)
|
|
||||||
|
|
||||||
winner_id = p1_id if s1 > s2 else p2_id
|
|
||||||
loser_id = p2_id if s1 > s2 else p1_id
|
|
||||||
winners_r1.append(winner_id)
|
|
||||||
losers_r1.append(loser_id)
|
|
||||||
|
|
||||||
# Update entry stats
|
|
||||||
for entry in entries:
|
|
||||||
if entry.player_id == winner_id:
|
|
||||||
entry.wins += 1
|
|
||||||
elif entry.player_id == loser_id:
|
|
||||||
entry.losses += 1
|
|
||||||
entry.is_in_losers = True
|
|
||||||
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
# Round 2 Winners Bracket - 2 matches - 1 completed, 1 in progress
|
|
||||||
# Match 1: players[0] vs players[1]
|
|
||||||
wm1 = TournamentMatch(
|
|
||||||
tournament_id=t.id,
|
|
||||||
round_number=2,
|
|
||||||
match_number=1,
|
|
||||||
bracket_type=BracketType.WINNERS,
|
|
||||||
status=TournamentMatchStatus.COMPLETED,
|
|
||||||
player1_id=winners_r1[0],
|
|
||||||
player2_id=winners_r1[1],
|
|
||||||
team1_score=11,
|
|
||||||
team2_score=8,
|
|
||||||
winner_team=1,
|
|
||||||
completed_at=datetime.utcnow() - timedelta(minutes=45),
|
|
||||||
)
|
|
||||||
db.add(wm1)
|
|
||||||
|
|
||||||
# Update entries
|
|
||||||
for entry in entries:
|
|
||||||
if entry.player_id == winners_r1[0]:
|
|
||||||
entry.wins += 1
|
|
||||||
elif entry.player_id == winners_r1[1]:
|
|
||||||
entry.losses += 1
|
|
||||||
entry.is_in_losers = True
|
|
||||||
|
|
||||||
# Match 2: players[3] (seed4 winner) vs players[5] (seed6 winner)
|
|
||||||
wm2 = TournamentMatch(
|
|
||||||
tournament_id=t.id,
|
|
||||||
round_number=2,
|
|
||||||
match_number=2,
|
|
||||||
bracket_type=BracketType.WINNERS,
|
|
||||||
status=TournamentMatchStatus.IN_PROGRESS,
|
|
||||||
player1_id=winners_r1[2],
|
|
||||||
player2_id=winners_r1[3],
|
|
||||||
team1_score=6,
|
|
||||||
team2_score=7,
|
|
||||||
)
|
|
||||||
db.add(wm2)
|
|
||||||
|
|
||||||
# Assign court 3 for active tournament match
|
|
||||||
m_active = Match(
|
|
||||||
court_id=courts[2].id,
|
|
||||||
stage=MatchStage.TOURNAMENT,
|
|
||||||
match_type=MatchType.SINGLES,
|
|
||||||
status=MatchStatus.IN_PROGRESS,
|
|
||||||
title=f"Tournament R2 - {db.query(Player).filter(Player.id == winners_r1[2]).first().name} vs {db.query(Player).filter(Player.id == winners_r1[3]).first().name}",
|
|
||||||
max_players=2,
|
|
||||||
team1_score=6,
|
|
||||||
team2_score=7,
|
|
||||||
started_at=datetime.utcnow() - timedelta(minutes=20),
|
|
||||||
)
|
|
||||||
db.add(m_active)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
for player_id, team in [(winners_r1[2], 1), (winners_r1[3], 2)]:
|
|
||||||
mp = MatchPlayer(match_id=m_active.id, player_id=player_id, team=team,
|
|
||||||
elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating)
|
|
||||||
db.add(mp)
|
|
||||||
|
|
||||||
# Losers Bracket Round 1 - 2 matches (losers from r1 matched up)
|
|
||||||
lb1 = TournamentMatch(
|
|
||||||
tournament_id=t.id,
|
|
||||||
round_number=1,
|
|
||||||
match_number=1,
|
|
||||||
bracket_type=BracketType.LOSERS,
|
|
||||||
status=TournamentMatchStatus.COMPLETED,
|
|
||||||
player1_id=losers_r1[0],
|
|
||||||
player2_id=losers_r1[1],
|
|
||||||
team1_score=9,
|
|
||||||
team2_score=11,
|
|
||||||
winner_team=2,
|
|
||||||
completed_at=datetime.utcnow() - timedelta(minutes=90),
|
|
||||||
)
|
|
||||||
db.add(lb1)
|
|
||||||
|
|
||||||
lb2 = TournamentMatch(
|
|
||||||
tournament_id=t.id,
|
|
||||||
round_number=1,
|
|
||||||
match_number=2,
|
|
||||||
bracket_type=BracketType.LOSERS,
|
|
||||||
status=TournamentMatchStatus.COMPLETED,
|
|
||||||
player1_id=losers_r1[2],
|
|
||||||
player2_id=losers_r1[3],
|
|
||||||
team1_score=11,
|
|
||||||
team2_score=6,
|
|
||||||
winner_team=1,
|
|
||||||
completed_at=datetime.utcnow() - timedelta(minutes=60),
|
|
||||||
)
|
|
||||||
db.add(lb2)
|
|
||||||
|
|
||||||
# Update loser entries
|
|
||||||
for entry in entries:
|
|
||||||
if entry.player_id == losers_r1[0]:
|
|
||||||
entry.losses += 1
|
|
||||||
entry.is_eliminated = True
|
|
||||||
entry.final_rank = 8
|
|
||||||
elif entry.player_id == losers_r1[3]:
|
|
||||||
entry.losses += 1
|
|
||||||
entry.is_eliminated = True
|
|
||||||
entry.final_rank = 7
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(t)
|
|
||||||
print(f"Created tournament: {t.name}")
|
|
||||||
return t
|
|
||||||
|
|
||||||
|
|
||||||
def seed_completed_matches(db: Session, players: list, courts: list):
|
|
||||||
"""Create some historical completed matches"""
|
|
||||||
past_matches = []
|
|
||||||
for i in range(6):
|
|
||||||
start = datetime.utcnow() - timedelta(days=i + 1, hours=2)
|
|
||||||
p_list = random.sample(players, 4)
|
|
||||||
m = Match(
|
m = Match(
|
||||||
court_id=courts[i % len(courts)].id,
|
stage_id=stage1_e1.id,
|
||||||
stage=MatchStage.OPEN if i % 2 == 0 else MatchStage.SKILL_BASED,
|
court_id=courts[i % 2].id,
|
||||||
match_type=MatchType.DOUBLES,
|
round_number=1 if i < 4 else 2,
|
||||||
status=MatchStatus.COMPLETED,
|
match_number=(i % 4) + 1,
|
||||||
title=f"Completed Match #{i + 1}",
|
player1_id=p1.id, player2_id=p2.id,
|
||||||
max_players=4,
|
score1=s1, score2=s2,
|
||||||
team1_score=11,
|
winner_id=winner.id,
|
||||||
team2_score=random.randint(5, 10),
|
status="completed",
|
||||||
started_at=start,
|
bracket_position="main",
|
||||||
ended_at=start + timedelta(hours=1),
|
started_at=completed_at_base + timedelta(minutes=i * 20),
|
||||||
|
completed_at=completed_at_base + timedelta(minutes=i * 20 + 18),
|
||||||
)
|
)
|
||||||
db.add(m)
|
db.add(m)
|
||||||
db.flush()
|
db.commit()
|
||||||
|
|
||||||
for player, team in zip(p_list, [1, 1, 2, 2]):
|
# Tournament bracket for event 1 (QF, SF, Final)
|
||||||
mp = MatchPlayer(match_id=m.id, player_id=player.id, team=team,
|
# Top 4 by round wins: Marco, Ana, Carlos, Lisa
|
||||||
elo_before=player.elo_rating, elo_after=player.elo_rating + random.uniform(-10, 10),
|
top4 = [players[0], players[1], players[2], players[5]] # Marco, Ana, Carlos, Lisa
|
||||||
elo_change=random.uniform(-10, 10), is_winner=(team == 1))
|
|
||||||
db.add(mp)
|
|
||||||
past_matches.append(m)
|
|
||||||
|
|
||||||
db.commit()
|
# QF matches
|
||||||
print(f"Created {len(past_matches)} completed matches")
|
qf1 = Match(stage_id=stage2_e1.id, court_id=courts[0].id, round_number=1, match_number=1,
|
||||||
|
player1_id=top4[0].id, player2_id=top4[3].id, score1=21, score2=13,
|
||||||
|
winner_id=top4[0].id, status="completed", bracket_position="winners",
|
||||||
|
started_at=now - timedelta(days=7, minutes=90),
|
||||||
|
completed_at=now - timedelta(days=7, minutes=72))
|
||||||
|
qf2 = Match(stage_id=stage2_e1.id, court_id=courts[1].id, round_number=1, match_number=2,
|
||||||
|
player1_id=top4[1].id, player2_id=top4[2].id, score1=21, score2=19,
|
||||||
|
winner_id=top4[1].id, status="completed", bracket_position="winners",
|
||||||
|
started_at=now - timedelta(days=7, minutes=90),
|
||||||
|
completed_at=now - timedelta(days=7, minutes=70))
|
||||||
|
db.add(qf1); db.add(qf2); db.commit()
|
||||||
|
db.refresh(qf1); db.refresh(qf2)
|
||||||
|
|
||||||
|
# Final
|
||||||
|
final = Match(stage_id=stage2_e1.id, court_id=courts[2].id, round_number=2, match_number=1,
|
||||||
|
player1_id=top4[0].id, player2_id=top4[1].id, score1=21, score2=17,
|
||||||
|
winner_id=top4[0].id, status="completed", bracket_position="winners",
|
||||||
|
started_at=now - timedelta(days=7, minutes=45),
|
||||||
|
completed_at=now - timedelta(days=7, minutes=25))
|
||||||
|
db.add(final); db.commit()
|
||||||
|
|
||||||
def main():
|
# Link bracket
|
||||||
print("🌱 Seeding ServeSync demo data...")
|
qf1.next_winner_match_id = final.id
|
||||||
|
qf2.next_winner_match_id = final.id
|
||||||
|
event1.current_stage_id = stage2_e1.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
db = SessionLocal()
|
print(f" Completed event created: {event1.name}")
|
||||||
try:
|
|
||||||
clear_data(db)
|
|
||||||
players = seed_players(db)
|
|
||||||
courts = seed_courts(db)
|
|
||||||
seed_bookings(db, players, courts)
|
|
||||||
seed_active_matches(db, players, courts)
|
|
||||||
seed_tournament(db, players, courts)
|
|
||||||
seed_completed_matches(db, players, courts)
|
|
||||||
print("✅ Seed complete!")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
# ─── EVENT 2: SUMMER TOURNAMENT (In Progress, Round 2) ───────────────────────
|
||||||
|
print("Creating in-progress event...")
|
||||||
|
event2 = Event(
|
||||||
|
name="Summer Tournament",
|
||||||
|
format="Singles",
|
||||||
|
courts_count=2,
|
||||||
|
status="active",
|
||||||
|
created_at=now - timedelta(hours=3),
|
||||||
|
started_at=now - timedelta(hours=2),
|
||||||
|
)
|
||||||
|
db.add(event2)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(event2)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
# Add 8 players
|
||||||
main()
|
for p in players:
|
||||||
|
ep = EventPlayer(event_id=event2.id, player_id=p.id)
|
||||||
|
db.add(ep)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Stage 1: Open Stage (completed)
|
||||||
|
stage1_e2 = Stage(event_id=event2.id, name="Open Stage", match_type="Open",
|
||||||
|
rounds=1, advance_count=4, status="completed", order=0)
|
||||||
|
db.add(stage1_e2)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(stage1_e2)
|
||||||
|
|
||||||
|
# Stage 1 completed matches
|
||||||
|
s1_matches = [
|
||||||
|
(players[0], players[7], 21, 6, "completed"),
|
||||||
|
(players[1], players[6], 21, 11, "completed"),
|
||||||
|
(players[2], players[5], 21, 15, "completed"),
|
||||||
|
(players[3], players[4], 21, 19, "completed"),
|
||||||
|
]
|
||||||
|
for i, (p1, p2, s1, s2, status) in enumerate(s1_matches):
|
||||||
|
winner = p1 if s1 > s2 else p2
|
||||||
|
m = Match(
|
||||||
|
stage_id=stage1_e2.id,
|
||||||
|
court_id=courts[i % 2].id,
|
||||||
|
round_number=1, match_number=i + 1,
|
||||||
|
player1_id=p1.id, player2_id=p2.id,
|
||||||
|
score1=s1, score2=s2, winner_id=winner.id,
|
||||||
|
status=status, bracket_position="main",
|
||||||
|
started_at=now - timedelta(hours=2, minutes=30),
|
||||||
|
completed_at=now - timedelta(hours=1, minutes=45),
|
||||||
|
)
|
||||||
|
db.add(m)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Stage 2: Tournament (Active, Round 2 in progress)
|
||||||
|
stage2_e2 = Stage(event_id=event2.id, name="Finals Tournament", match_type="Tournament",
|
||||||
|
rounds=3, advance_count=0, status="active", order=1)
|
||||||
|
db.add(stage2_e2)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(stage2_e2)
|
||||||
|
event2.current_stage_id = stage2_e2.id
|
||||||
|
|
||||||
|
# Tournament bracket - 8 players → QF (R1), SF (R2), Final (R3)
|
||||||
|
# QF: Round 1 (all completed)
|
||||||
|
qf_matches = [
|
||||||
|
(players[0], players[7], 21, 8, "completed", courts[0].id),
|
||||||
|
(players[1], players[6], 21, 13, "completed", courts[1].id),
|
||||||
|
(players[2], players[5], 21, 16, "completed", None),
|
||||||
|
(players[3], players[4], 19, 21, "completed", None), # Tony beats Diana!
|
||||||
|
]
|
||||||
|
|
||||||
|
# SF: Round 2 (2 in progress, 0 completed)
|
||||||
|
sf_matches = [
|
||||||
|
(players[0], players[1], None, None, "in_progress", courts[2].id), # Marco vs Ana - LIVE
|
||||||
|
(players[2], players[4], None, None, "in_progress", courts[3].id), # Carlos vs Tony - LIVE
|
||||||
|
]
|
||||||
|
|
||||||
|
# Final: Round 3 (scheduled)
|
||||||
|
final_e2 = Match(
|
||||||
|
stage_id=stage2_e2.id, court_id=None,
|
||||||
|
round_number=3, match_number=1,
|
||||||
|
player1_id=None, player2_id=None,
|
||||||
|
status="scheduled", bracket_position="winners",
|
||||||
|
)
|
||||||
|
db.add(final_e2); db.flush(); db.refresh(final_e2)
|
||||||
|
|
||||||
|
# Create QF matches
|
||||||
|
qf_db = []
|
||||||
|
for i, (p1, p2, s1, s2, status, court_id) in enumerate(qf_matches):
|
||||||
|
winner = p1 if (s1 and s1 > s2) else p2
|
||||||
|
m = Match(
|
||||||
|
stage_id=stage2_e2.id, court_id=court_id,
|
||||||
|
round_number=1, match_number=i + 1,
|
||||||
|
player1_id=p1.id, player2_id=p2.id,
|
||||||
|
score1=s1, score2=s2, winner_id=winner.id if status == "completed" else None,
|
||||||
|
status=status, bracket_position="winners",
|
||||||
|
started_at=now - timedelta(hours=1, minutes=30),
|
||||||
|
completed_at=now - timedelta(hours=1) if status == "completed" else None,
|
||||||
|
)
|
||||||
|
db.add(m)
|
||||||
|
qf_db.append(m)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
for m in qf_db:
|
||||||
|
db.refresh(m)
|
||||||
|
|
||||||
|
# Create SF matches
|
||||||
|
sf_db = []
|
||||||
|
for i, (p1, p2, s1, s2, status, court_id) in enumerate(sf_matches):
|
||||||
|
m = Match(
|
||||||
|
stage_id=stage2_e2.id, court_id=court_id,
|
||||||
|
round_number=2, match_number=i + 1,
|
||||||
|
player1_id=p1.id, player2_id=p2.id,
|
||||||
|
score1=s1, score2=s2,
|
||||||
|
status=status, bracket_position="winners",
|
||||||
|
started_at=now - timedelta(minutes=15) if status == "in_progress" else None,
|
||||||
|
next_winner_match_id=final_e2.id,
|
||||||
|
)
|
||||||
|
db.add(m)
|
||||||
|
sf_db.append(m)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Link QF to SF
|
||||||
|
qf_db[0].next_winner_match_id = sf_db[0].id
|
||||||
|
qf_db[1].next_winner_match_id = sf_db[0].id
|
||||||
|
qf_db[2].next_winner_match_id = sf_db[1].id
|
||||||
|
qf_db[3].next_winner_match_id = sf_db[1].id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print(f" In-progress event created: {event2.name}")
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
print("\n✅ Seed complete!")
|
||||||
|
print(f" Players: {len(players)}")
|
||||||
|
print(f" Courts: {len(courts)}")
|
||||||
|
print(f" Events: 2 (1 completed, 1 active)")
|
||||||
|
print(f" Courts C & D: in_use (live matches)")
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
echo "Waiting for database..."
|
|
||||||
until python -c "
|
|
||||||
import psycopg2, os
|
|
||||||
try:
|
|
||||||
conn = psycopg2.connect(os.environ.get('DATABASE_URL', 'postgresql://servesync:servesync@db:5432/servesync'))
|
|
||||||
conn.close()
|
|
||||||
print('DB ready')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'DB not ready: {e}')
|
|
||||||
exit(1)
|
|
||||||
"; do
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Running seed..."
|
echo "Waiting for database..."
|
||||||
cd /app && python seed.py && echo "Seed complete" || echo "Seed failed (continuing)"
|
sleep 3
|
||||||
|
|
||||||
|
echo "Running migrations..."
|
||||||
|
python -c "from app.database import engine; from app.models import Base; Base.metadata.create_all(bind=engine)"
|
||||||
|
|
||||||
|
echo "Seeding data..."
|
||||||
|
python /app/seed.py || echo "Seed already exists or failed (non-fatal)"
|
||||||
|
|
||||||
echo "Starting server..."
|
echo "Starting server..."
|
||||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
@@ -9,6 +8,7 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf
|
COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ServeSync — Pickleball Court Management</title>
|
<title>ServeSync — Court Manager</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏓</text></svg>"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "servesync-frontend",
|
"name": "servesync-frontend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -9,15 +8,16 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.2.5",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"axios": "^1.6.7"
|
"@vueuse/core": "^10.7.0",
|
||||||
|
"axios": "^1.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
"vite": "^5.2.0",
|
"vite": "^5.1.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.17",
|
||||||
"postcss": "^8.4.35"
|
"postcss": "^8.4.35"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +1,71 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-950">
|
<div class="min-h-screen bg-gray-950">
|
||||||
<!-- Navbar - hidden on screen display pages -->
|
<!-- Hide nav on screen view -->
|
||||||
<nav v-if="!isScreenDisplay" class="bg-gray-900 border-b border-gray-800 sticky top-0 z-50">
|
<nav v-if="!isScreenView" class="bg-gray-900 border-b border-gray-800 px-4 py-3">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
<div class="flex items-center justify-between h-16">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Logo -->
|
|
||||||
<router-link to="/" class="flex items-center gap-2">
|
|
||||||
<span class="text-2xl">🏓</span>
|
<span class="text-2xl">🏓</span>
|
||||||
<span class="text-xl font-bold text-white">Serve<span class="text-green-400">Sync</span></span>
|
<span class="text-xl font-bold text-white">ServeSync</span>
|
||||||
</router-link>
|
<span class="text-xs text-gray-500 ml-1">Court Manager</span>
|
||||||
|
</div>
|
||||||
<!-- Nav Links -->
|
<div class="flex items-center gap-1">
|
||||||
<div class="hidden md:flex items-center gap-1">
|
<router-link
|
||||||
<router-link v-for="link in navLinks" :key="link.to" :to="link.to"
|
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="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'">
|
:class="isActive(link.to)
|
||||||
{{ link.icon }} {{ link.label }}
|
? '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>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<!-- Current Player -->
|
<span class="text-xs text-gray-600">{{ timeStr }}</span>
|
||||||
<div v-if="store.currentPlayer" class="flex items-center gap-3">
|
<router-link to="/events/new" class="btn-primary btn-sm">
|
||||||
<div class="flex items-center gap-2 bg-gray-800 rounded-lg px-3 py-2">
|
+ New Event
|
||||||
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white"
|
</router-link>
|
||||||
: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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile nav -->
|
<main :class="isScreenView ? '' : 'max-w-7xl mx-auto px-4 py-6'">
|
||||||
<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 />
|
<router-view />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useAppStore } from './stores/app'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAppStore()
|
|
||||||
|
|
||||||
const isScreenDisplay = computed(() => route.name === 'Screen')
|
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: '/', label: 'Dashboard', icon: '📊' },
|
{ to: '/', icon: '📺', label: 'Dashboard' },
|
||||||
{ to: '/courts', label: 'Courts', icon: '🏟️' },
|
{ to: '/players', icon: '👥', label: 'Players' },
|
||||||
{ to: '/matchmaking', label: 'Matchmaking', icon: '⚔️' },
|
{ to: '/courts', icon: '🏟️', label: 'Courts' },
|
||||||
{ to: '/tournament', label: 'Tournament', icon: '🏆' },
|
{ to: '/events', icon: '🎮', label: 'Events' },
|
||||||
{ to: '/players', label: 'Players', icon: '👥' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const tierClass = (tier) => {
|
const isScreenView = computed(() => route.path.startsWith('/screen'))
|
||||||
const classes = {
|
const isActive = (path) => {
|
||||||
bronze: 'bg-amber-900/50 text-amber-400',
|
if (path === '/') return route.path === '/'
|
||||||
silver: 'bg-gray-700/50 text-gray-300',
|
return route.path.startsWith(path)
|
||||||
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 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'
|
update()
|
||||||
}
|
timer = setInterval(update, 1000)
|
||||||
|
|
||||||
const switchPlayer = (playerId) => {
|
|
||||||
const player = store.players.find(p => p.id == playerId)
|
|
||||||
if (player) store.setCurrentPlayer(player)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await store.loadPlayers()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => clearInterval(timer))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import App from './App.vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
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 = [
|
const routes = [
|
||||||
{ path: '/', name: 'Dashboard', component: Dashboard },
|
{ path: '/', component: () => import('../views/Dashboard.vue'), name: 'dashboard' },
|
||||||
{ path: '/courts', name: 'Courts', component: Courts },
|
{ path: '/players', component: () => import('../views/Players.vue'), name: 'players' },
|
||||||
{ path: '/matchmaking', name: 'Matchmaking', component: Matchmaking },
|
{ path: '/courts', component: () => import('../views/Courts.vue'), name: 'courts' },
|
||||||
{ path: '/tournament', name: 'Tournament', component: Tournament },
|
{ path: '/events', component: () => import('../views/Events.vue'), name: 'events' },
|
||||||
{ path: '/players', name: 'Players', component: Players },
|
{ path: '/events/new', component: () => import('../views/EventBuilder.vue'), name: 'event-new' },
|
||||||
{ path: '/screen/:courtId', name: 'Screen', component: ScreenDisplay },
|
{ 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(),
|
history: createWebHistory(),
|
||||||
routes,
|
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 {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-950 text-gray-100 font-sans;
|
@apply bg-gray-950 text-gray-100 min-h-screen;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.btn {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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>
|
<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>
|
|
||||||
|
|
||||||
<!-- 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>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-white">{{ court.name }}</h2>
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div class="text-sm text-gray-400">{{ court.surface_type }}</div>
|
<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>
|
||||||
<div class="text-right">
|
</div>
|
||||||
<span class="badge text-sm"
|
|
||||||
:class="court.current_status === 'occupied' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'">
|
<div class="grid grid-cols-2 gap-6">
|
||||||
{{ court.current_status === 'occupied' ? '🔴 In Use' : '🟢 Available' }}
|
<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-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>
|
</span>
|
||||||
<div class="text-sm text-gray-400 mt-1">₱{{ court.hourly_rate }}/hr</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Status override -->
|
||||||
<div class="flex flex-wrap gap-1 mb-4">
|
<div class="flex gap-2 mb-4">
|
||||||
<span v-for="f in court.features.split(',')" :key="f"
|
<button v-for="s in statuses" :key="s.val"
|
||||||
class="badge bg-gray-800 text-gray-300 text-xs">{{ f.trim() }}</span>
|
@click="setStatus(court.id, s.val)"
|
||||||
</div>
|
: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">
|
||||||
<!-- Active Match -->
|
{{ s.label }}
|
||||||
<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)}` }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TV Screen Link -->
|
<!-- Reservations -->
|
||||||
<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>
|
<div>
|
||||||
<h2 class="text-lg font-bold text-white mb-4">📅 Upcoming Bookings</h2>
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="card p-0 overflow-hidden">
|
<h3 class="text-sm font-medium text-gray-400">Reservations</h3>
|
||||||
<div v-if="upcomingBookings.length === 0" class="text-center py-8 text-gray-500">
|
<button @click="openReserve(court)" class="text-xs text-green-400 hover:text-green-300">+ Reserve</button>
|
||||||
No upcoming bookings
|
|
||||||
</div>
|
</div>
|
||||||
<div v-for="booking in upcomingBookings" :key="booking.id"
|
<div v-if="court.reservations && court.reservations.length === 0" class="text-xs text-gray-600 italic">No reservations</div>
|
||||||
class="flex items-center justify-between px-4 py-3 border-b border-gray-800 last:border-0">
|
<div v-else class="space-y-2">
|
||||||
<div class="flex items-center gap-3">
|
<div v-for="r in court.reservations" :key="r.id"
|
||||||
<span class="text-2xl">📅</span>
|
class="bg-gray-800 rounded-lg p-3 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-medium text-white">{{ booking.player_name }}</div>
|
<p class="text-sm text-white">{{ r.group_name || r.player?.name || 'Unknown' }}</p>
|
||||||
<div class="text-xs text-gray-400">{{ booking.court_name }}</div>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Success Toast -->
|
<!-- Reserve Modal -->
|
||||||
<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">
|
<div v-if="reserveModal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||||
✅ {{ successMsg }}
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, reactive } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAppStore } from '../stores/app'
|
import api, { courtStatusClass, formatTime, formatDate } from '../stores/api'
|
||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
const store = useAppStore()
|
|
||||||
const courts = ref([])
|
const courts = ref([])
|
||||||
const upcomingBookings = ref([])
|
const reserveModal = ref(null)
|
||||||
const loading = reactive({})
|
const reserveForm = ref({})
|
||||||
const bookingForms = reactive({})
|
|
||||||
const successMsg = ref('')
|
|
||||||
|
|
||||||
const availableCourts = computed(() => courts.value.filter(c => c.current_status === 'available').length)
|
const statuses = [
|
||||||
const occupiedCourts = computed(() => courts.value.filter(c => c.current_status === 'occupied').length)
|
{ 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) => {
|
async function load() {
|
||||||
return new Date(isoString).toLocaleString('en-PH', {
|
const { data } = await api.get('/courts/')
|
||||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
courts.value = data
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookCourt = async (court) => {
|
async function setStatus(courtId, status) {
|
||||||
if (!store.currentPlayer) return
|
await api.patch(`/courts/${courtId}/status`, { status })
|
||||||
loading[court.id] = true
|
await load()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function openReserve(court) {
|
||||||
courts.value = await store.loadCourts()
|
|
||||||
|
|
||||||
// Init booking forms
|
|
||||||
courts.value.forEach(court => {
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
now.setMinutes(0, 0, 0)
|
const pad = n => String(n).padStart(2, '0')
|
||||||
now.setHours(now.getHours() + 1)
|
const fmt = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
bookingForms[court.id] = {
|
reserveModal.value = { court }
|
||||||
start_time: now.toISOString().slice(0, 16),
|
reserveForm.value = {
|
||||||
duration: '1',
|
group_name: '',
|
||||||
|
start_time: fmt(now),
|
||||||
|
end_time: fmt(new Date(now.getTime() + 2 * 3600000)),
|
||||||
|
notes: '',
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
try {
|
async function submitReservation() {
|
||||||
const res = await axios.get('/api/courts/bookings/upcoming')
|
if (!reserveModal.value) return
|
||||||
upcomingBookings.value = res.data
|
await api.post(`/courts/${reserveModal.value.court.id}/reservations`, {
|
||||||
} catch (e) {}
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReservation(id) {
|
||||||
|
await api.delete(`/courts/reservations/${id}`)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,188 +1,222 @@
|
|||||||
<template>
|
<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>
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</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 }}
|
|
||||||
</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>
|
|
||||||
</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>
|
||||||
<div class="font-bold text-white text-sm">{{ tournament.tournament?.name }}</div>
|
<!-- Header -->
|
||||||
<div class="text-xs text-purple-400 capitalize">{{ tournament.tournament?.status?.replace('_', ' ') }}</div>
|
<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="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>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-2 text-center">
|
|
||||||
<div class="bg-purple-900/30 rounded-lg p-2">
|
<!-- Active event banner -->
|
||||||
<div class="text-lg font-bold text-white">{{ tournament.winners_bracket?.length || 0 }}</div>
|
<div v-if="activeEvent" class="card p-4 mb-6 border-green-800 bg-green-950/30">
|
||||||
<div class="text-xs text-gray-400">W Bracket</div>
|
<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>
|
</div>
|
||||||
<div class="bg-purple-900/30 rounded-lg p-2">
|
<router-link :to="`/events/${activeEvent.id}`" class="text-green-400 text-sm hover:underline">Manage →</router-link>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<router-link to="/tournament" class="btn btn-primary w-full text-center mt-3 block text-sm">
|
|
||||||
View Bracket →
|
<!-- Court Grid (2x2) -->
|
||||||
</router-link>
|
<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>
|
||||||
|
<button @click="$router.push(`/screen/${court.id}`)"
|
||||||
|
class="text-xs text-gray-600 hover:text-gray-400">📺 Screen</button>
|
||||||
|
</div>
|
||||||
|
</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="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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Bottom split: Upcoming + Bracket -->
|
||||||
<div class="card">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<h2 class="text-lg font-bold text-white mb-4">Quick Actions</h2>
|
<!-- Upcoming matches -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div class="col-span-2 card p-4">
|
||||||
<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">
|
<h3 class="font-semibold text-gray-300 mb-3">📋 Upcoming Matches</h3>
|
||||||
<span class="text-3xl">⚔️</span>
|
<div v-if="upcoming.length === 0" class="text-gray-600 text-sm py-4 text-center">No upcoming matches</div>
|
||||||
<span class="text-sm font-medium text-green-400">Find Match</span>
|
<div v-else class="space-y-2">
|
||||||
</router-link>
|
<div v-for="m in upcoming" :key="m.id"
|
||||||
<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">
|
class="flex items-center justify-between p-3 bg-gray-800 rounded-lg text-sm">
|
||||||
<span class="text-3xl">📅</span>
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-sm font-medium text-blue-400">Book Court</span>
|
<span class="text-gray-500 text-xs">R{{ m.round_number }}</span>
|
||||||
</router-link>
|
<span class="text-white font-medium">{{ m.player1_name }}</span>
|
||||||
<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-gray-600">vs</span>
|
||||||
<span class="text-3xl">🏆</span>
|
<span class="text-white font-medium">{{ m.player2_name }}</span>
|
||||||
<span class="text-sm font-medium text-purple-400">Tournament</span>
|
</div>
|
||||||
</router-link>
|
<div class="flex items-center gap-2 text-xs text-gray-500">
|
||||||
<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>{{ m.stage_name }}</span>
|
||||||
<span class="text-3xl">📺</span>
|
<button @click="startMatch(m.id)" class="btn-primary btn-sm text-xs px-2 py-1">▶ Start</button>
|
||||||
<span class="text-sm font-medium text-yellow-400">Live Screen</span>
|
</div>
|
||||||
</router-link>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useAppStore } from '../stores/app'
|
import api, { courtStatusClass, formatElapsed, formatTime } from '../stores/api'
|
||||||
|
|
||||||
const store = useAppStore()
|
|
||||||
const courts = ref([])
|
const courts = ref([])
|
||||||
const leaderboard = ref([])
|
const upcoming = ref([])
|
||||||
const lobbyData = ref({ lobby: [], active: [] })
|
const activeEvent = ref(null)
|
||||||
const tournament = ref(null)
|
const scoreModal = ref(null)
|
||||||
const players = ref([])
|
const scoreInput1 = ref(0)
|
||||||
|
const scoreInput2 = ref(0)
|
||||||
|
let timer = null
|
||||||
|
|
||||||
const availableCourts = computed(() => courts.value.filter(c => c.current_status === 'available').length)
|
async function load() {
|
||||||
const activeMatches = computed(() => lobbyData.value.active?.length || 0)
|
try {
|
||||||
const lobbyMatches = computed(() => lobbyData.value.lobby?.length || 0)
|
const { data } = await api.get('/dashboard/')
|
||||||
|
courts.value = data.courts
|
||||||
const tierColorClass = (tier) => {
|
upcoming.value = data.upcoming_matches
|
||||||
const classes = {
|
activeEvent.value = data.active_event
|
||||||
bronze: 'text-amber-600',
|
} catch (e) {
|
||||||
silver: 'text-gray-400',
|
console.error(e)
|
||||||
gold: 'text-yellow-400',
|
|
||||||
platinum: 'text-cyan-400',
|
|
||||||
elite: 'text-purple-400',
|
|
||||||
}
|
}
|
||||||
return classes[tier] || 'text-gray-400'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
async function startMatch(id) {
|
||||||
courts.value = await store.loadCourts()
|
await api.post(`/matches/${id}/start`)
|
||||||
leaderboard.value = await store.getLeaderboard(8)
|
await load()
|
||||||
players.value = await store.loadPlayers()
|
}
|
||||||
|
|
||||||
try {
|
function openScoreModal(match) {
|
||||||
const [lobby, active] = await Promise.all([store.loadLobby(), store.loadActiveMatches()])
|
scoreModal.value = match
|
||||||
lobbyData.value = { lobby, active }
|
scoreInput1.value = match.score1 ?? 0
|
||||||
} catch (e) {}
|
scoreInput2.value = match.score2 ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
async function submitScore() {
|
||||||
const tournaments = await store.loadTournaments()
|
if (!scoreModal.value) return
|
||||||
const active = tournaments.find(t => t.status === 'in_progress')
|
await api.post(`/matches/${scoreModal.value.id}/score`, {
|
||||||
if (active) tournament.value = await store.loadTournament(active.id)
|
score1: scoreInput1.value,
|
||||||
} catch (e) {}
|
score2: scoreInput2.value,
|
||||||
|
})
|
||||||
|
scoreModal.value = null
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
timer = setInterval(load, 5000)
|
||||||
})
|
})
|
||||||
|
onUnmounted(() => clearInterval(timer))
|
||||||
</script>
|
</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>
|
<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>
|
|
||||||
|
|
||||||
<!-- 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>
|
||||||
<div class="text-sm font-medium" :class="tier.color">{{ tier.name }}</div>
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div class="text-xs text-gray-500">{{ tier.count }} players</div>
|
<div>
|
||||||
</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>
|
</div>
|
||||||
|
<button @click="openAdd" class="btn-primary">+ Add Player</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Players Table -->
|
<!-- Player Table -->
|
||||||
<div class="card p-0 overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<table class="w-full">
|
<table class="w-full text-sm">
|
||||||
<thead>
|
<thead class="border-b border-gray-800">
|
||||||
<tr class="border-b border-gray-800 text-left">
|
<tr class="text-gray-500 text-xs uppercase tracking-wider">
|
||||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Rank</th>
|
<th class="text-left px-4 py-3">Player</th>
|
||||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Player</th>
|
<th class="text-left px-4 py-3">Contact</th>
|
||||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">ELO</th>
|
<th class="text-center px-4 py-3">Skill</th>
|
||||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Tier</th>
|
<th class="text-center px-4 py-3">ELO</th>
|
||||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">W/L</th>
|
<th class="text-center px-4 py-3">W / L</th>
|
||||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Win Rate</th>
|
<th class="text-center px-4 py-3">Played</th>
|
||||||
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Matches</th>
|
<th class="text-right px-4 py-3">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="divide-y divide-gray-800/50">
|
||||||
<tr v-for="(player, idx) in players" :key="player.id"
|
<tr v-for="(p, i) in players" :key="p.id" class="hover:bg-gray-800/30 transition-colors">
|
||||||
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">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center gap-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"
|
<div class="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-sm font-bold text-gray-300">
|
||||||
:style="{ backgroundColor: player.avatar_color }">
|
{{ i + 1 }}
|
||||||
{{ 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>
|
||||||
|
<span class="font-medium text-white">{{ p.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3 text-gray-400">{{ p.contact || '—' }}</td>
|
||||||
<span class="text-white font-mono font-bold">{{ Math.round(player.elo_rating) }}</span>
|
<td class="px-4 py-3 text-center">
|
||||||
|
<span :class="skillBadgeClass(p.skill_level)">{{ p.skill_level }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3 text-center font-mono font-bold text-white">{{ Math.round(p.elo) }}</td>
|
||||||
<span class="badge capitalize text-xs px-2 py-1"
|
<td class="px-4 py-3 text-center">
|
||||||
:class="tierBadge(player.membership_tier)">
|
<span class="text-green-400">{{ p.wins }}</span>
|
||||||
{{ 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-gray-600 mx-1">/</span>
|
||||||
<span class="text-red-400">{{ player.losses }}L</span>
|
<span class="text-red-400">{{ p.losses }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3 text-center text-gray-400">{{ p.matches_played }}</td>
|
||||||
<div class="flex items-center gap-2">
|
<td class="px-4 py-3 text-right">
|
||||||
<div class="flex-1 bg-gray-800 rounded-full h-1.5 w-16">
|
<button @click="openEdit(p)" class="text-xs text-blue-400 hover:text-blue-300 mr-3">Edit</button>
|
||||||
<div class="h-full rounded-full bg-green-500"
|
<button @click="confirmDelete(p)" class="text-xs text-red-500 hover:text-red-400">Delete</button>
|
||||||
:style="{ width: player.win_rate + '%' }"></div>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-400">{{ player.win_rate }}%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-gray-400">{{ player.total_matches }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div v-if="players.length === 0" class="text-center text-gray-600 py-12">
|
||||||
|
No players yet. Add your first player!
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Register Modal -->
|
<!-- Add/Edit 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 v-if="modal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||||
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-full max-w-md">
|
@click.self="modal = null">
|
||||||
<h3 class="text-lg font-bold text-white mb-4">👤 Register New Player</h3>
|
<div class="card p-6 w-full max-w-md">
|
||||||
<div class="space-y-3">
|
<h3 class="text-lg font-bold text-white mb-4">{{ modal.id ? 'Edit' : 'Add' }} Player</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-400">Full Name *</label>
|
<label class="label">Name *</label>
|
||||||
<input v-model="registerForm.name" type="text" placeholder="Juan Dela Cruz"
|
<input v-model="modal.name" class="input" placeholder="Full name" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-400">Email *</label>
|
<label class="label">Contact</label>
|
||||||
<input v-model="registerForm.email" type="email" placeholder="juan@email.com"
|
<input v-model="modal.contact" class="input" placeholder="Phone or email" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-400">Phone</label>
|
<label class="label">Skill Level *</label>
|
||||||
<input v-model="registerForm.phone" type="tel" placeholder="+63 912 345 6789"
|
<select v-model="modal.skill_level" class="input">
|
||||||
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="Beginner">Beginner</option>
|
||||||
</div>
|
<option value="Intermediate">Intermediate</option>
|
||||||
<div class="bg-gray-800 rounded-lg p-3 text-xs text-gray-400">
|
<option value="Advanced">Advanced</option>
|
||||||
New players start at <span class="text-white font-bold">1000 ELO</span> (Bronze tier).
|
<option value="Elite">Elite</option>
|
||||||
Rating updates automatically after each match.
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 mt-4">
|
<div class="flex gap-2 mt-6">
|
||||||
<button @click="showRegister = false" class="btn btn-secondary flex-1">Cancel</button>
|
<button @click="modal = null" class="btn-secondary flex-1">Cancel</button>
|
||||||
<button @click="registerPlayer" :disabled="!registerForm.name || !registerForm.email"
|
<button @click="savePlayer" :disabled="!modal.name" class="btn-primary flex-1">
|
||||||
class="btn btn-primary flex-1">Register</button>
|
{{ modal.id ? 'Save Changes' : 'Add Player' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,52 +105,55 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, reactive, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAppStore } from '../stores/app'
|
import api, { skillBadgeClass } from '../stores/api'
|
||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
const store = useAppStore()
|
|
||||||
const players = ref([])
|
const players = ref([])
|
||||||
const showRegister = ref(false)
|
const modal = ref(null)
|
||||||
const registerForm = reactive({ name: '', email: '', phone: '' })
|
const deleteTarget = ref(null)
|
||||||
|
|
||||||
const tierStats = computed(() => {
|
async function load() {
|
||||||
const tiers = { bronze: 0, silver: 0, gold: 0, platinum: 0, elite: 0 }
|
const { data } = await api.get('/players/')
|
||||||
players.value.forEach(p => { tiers[p.membership_tier] = (tiers[p.membership_tier] || 0) + 1 })
|
players.value = data
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const registerPlayer = async () => {
|
function openAdd() {
|
||||||
try {
|
modal.value = { name: '', contact: '', skill_level: 'Intermediate' }
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function openEdit(p) {
|
||||||
players.value = await store.loadPlayers()
|
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>
|
</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>
|
|
||||||
@@ -1,30 +1,19 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: ['./index.html', './src/**/*.{vue,js,ts}'],
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
'pickle': {
|
brand: {
|
||||||
50: '#f0fdf4',
|
50: '#f0fdf4',
|
||||||
100: '#dcfce7',
|
100: '#dcfce7',
|
||||||
200: '#bbf7d0',
|
|
||||||
300: '#86efac',
|
|
||||||
400: '#4ade80',
|
|
||||||
500: '#22c55e',
|
500: '#22c55e',
|
||||||
600: '#16a34a',
|
600: '#16a34a',
|
||||||
700: '#15803d',
|
700: '#15803d',
|
||||||
800: '#166534',
|
|
||||||
900: '#14532d',
|
900: '#14532d',
|
||||||
}
|
}
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
|
||||||
'bounce-slow': 'bounce 2s infinite',
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,9 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': 'http://backend:8000',
|
||||||
target: 'http://backend:8000',
|
'/ws': { target: 'ws://backend:8000', ws: true },
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/ws': {
|
|
||||||
target: 'ws://backend:8000',
|
|
||||||
ws: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user