feat: Rebuild as Court Manager Dashboard v2

- Complete rewrite for single Court Manager user
- Feature 1: Player Management (CRUD, ELO, skill level)
- Feature 2: Court Reservation (timeline, override)
- Feature 3: Match Event Builder (3-step wizard)
  - Step 1: Event setup with player selection + stage calculator
  - Step 2: Stage configurator (Open/Skill/RR/Tournament)
  - Step 3: Auto bracket generation
- Feature 4: Live Court View (main dashboard, 2x2 grid)
- Event Control: score entry, bracket advancement, court assignment
- Screen view: TV display per court (/screen/:id)
- Seed: 8 players, 4 courts, 1 completed + 1 active event
- Routes: / players courts events events/new events/:id screen/:id
This commit is contained in:
Nemo
2026-03-19 07:35:51 +08:00
parent e455accc7e
commit 4421f61c24
56 changed files with 2949 additions and 4209 deletions

View File

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