Compare commits
+28
-29
@@ -28,7 +28,7 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
|
|||||||
type=data.type,
|
type=data.type,
|
||||||
)
|
)
|
||||||
new_t.teams = [models.Team(name=n) for n in data.teams]
|
new_t.teams = [models.Team(name=n) for n in data.teams]
|
||||||
new_t.courts = [models.Court(name=n) for n in data.courts]
|
new_t.courts = db.query(models.Court).filter(models.Court.id.in_(data.courts)).all()
|
||||||
|
|
||||||
db.add(new_t)
|
db.add(new_t)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -141,53 +141,52 @@ def delete_team(db: Session, tournament_id: str, team_id: int):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_courts(db: Session, tournament_id: str):
|
def get_courts(db: Session):
|
||||||
return (
|
return db.query(models.Court).all()
|
||||||
db.query(models.Court).filter(models.Court.tournament_id == tournament_id).all()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_court(db: Session, tournament_id: str, court_data: schemas.CourtCreate):
|
def create_court(db: Session, court_data: schemas.CourtCreate):
|
||||||
t = get_tournament(db, tournament_id)
|
new_court = models.Court(name=court_data.name)
|
||||||
if not t:
|
|
||||||
return None
|
|
||||||
|
|
||||||
new_court = models.Court(name=court_data.name, tournament_id=tournament_id)
|
|
||||||
db.add(new_court)
|
db.add(new_court)
|
||||||
db.flush()
|
|
||||||
|
|
||||||
logic.update_schedule_times(db, t)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_court)
|
db.refresh(new_court)
|
||||||
return new_court
|
return new_court
|
||||||
|
|
||||||
|
|
||||||
def delete_court(db: Session, tournament_id: str, court_id: int):
|
def delete_court(db: Session, court_id: int):
|
||||||
t = get_tournament(db, tournament_id)
|
|
||||||
if not t:
|
|
||||||
return None
|
|
||||||
court = db.get(models.Court, court_id)
|
court = db.get(models.Court, court_id)
|
||||||
if not court or court.tournament_id != tournament_id:
|
if not court:
|
||||||
return None
|
return False
|
||||||
|
|
||||||
|
affected_tournaments = list(court.tournaments)
|
||||||
|
|
||||||
|
matches = db.query(models.Match).filter(models.Match.court_id == court_id).all()
|
||||||
|
for m in matches:
|
||||||
|
if m.status == models.MatchStatus.FINISHED:
|
||||||
|
m.court_id = None
|
||||||
|
else:
|
||||||
|
m.court_id = None
|
||||||
|
m.start_time = None
|
||||||
|
|
||||||
db.delete(court)
|
db.delete(court)
|
||||||
db.flush()
|
|
||||||
|
|
||||||
logic.update_schedule_times(db, t)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
processed_days = set()
|
||||||
|
for t in affected_tournaments:
|
||||||
|
day = t.timestamp.date() if t.timestamp else None
|
||||||
|
if day and day not in processed_days:
|
||||||
|
logic.update_schedule_times(db, t)
|
||||||
|
processed_days.add(day)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def update_tournament_courts(
|
def update_tournament_courts(db: Session, tournament_id: str, new_court_ids: list[int]):
|
||||||
db: Session, tournament_id: str, new_court_names: list[str]
|
|
||||||
):
|
|
||||||
t = get_tournament(db, tournament_id)
|
t = get_tournament(db, tournament_id)
|
||||||
if not t:
|
if not t:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
t.courts = [models.Court(name=c, tournament_id=t.id) for c in new_court_names]
|
t.courts = db.query(models.Court).filter(models.Court.id.in_(new_court_ids)).all()
|
||||||
db.flush()
|
db.flush()
|
||||||
logic.update_schedule_times(db, t)
|
logic.update_schedule_times(db, t)
|
||||||
|
|
||||||
|
|||||||
+130
-55
@@ -1,6 +1,6 @@
|
|||||||
# backend/app/logic.py
|
# backend/app/logic.py
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -79,59 +79,108 @@ def generate_bracket(db: Session, t: models.Tournament):
|
|||||||
|
|
||||||
|
|
||||||
def update_schedule_times(db: Session, t: models.Tournament):
|
def update_schedule_times(db: Session, t: models.Tournament):
|
||||||
if not t.courts or not t.matches:
|
if not t.timestamp:
|
||||||
return
|
return
|
||||||
|
|
||||||
matches = t.matches
|
# 1. Grab the entire day of tournaments
|
||||||
match_map = {m.id: m for m in matches}
|
target_date = t.timestamp.date()
|
||||||
|
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||||
|
end_of_day = start_of_day + timedelta(days=1)
|
||||||
|
|
||||||
|
tournaments = (
|
||||||
|
db.query(models.Tournament)
|
||||||
|
.filter(
|
||||||
|
models.Tournament.timestamp >= start_of_day,
|
||||||
|
models.Tournament.timestamp < end_of_day,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tournaments:
|
||||||
|
return
|
||||||
|
|
||||||
|
all_matches: list[models.Match] = []
|
||||||
|
match_map: dict[str, models.Match] = {}
|
||||||
prereqs = defaultdict(list)
|
prereqs = defaultdict(list)
|
||||||
for m in matches:
|
|
||||||
if m.winner_next_match_id:
|
|
||||||
prereqs[m.winner_next_match_id].append(m.id)
|
|
||||||
if m.loser_next_match_id:
|
|
||||||
prereqs[m.loser_next_match_id].append(m.id)
|
|
||||||
|
|
||||||
depth_cache = {}
|
depth_cache = {}
|
||||||
|
|
||||||
|
# 2. Gather all matches and prereqs globally
|
||||||
|
for t_item in tournaments:
|
||||||
|
for m in t_item.matches:
|
||||||
|
all_matches.append(m)
|
||||||
|
match_map[m.id] = m
|
||||||
|
if m.winner_next_match_id:
|
||||||
|
prereqs[m.winner_next_match_id].append(m.id)
|
||||||
|
if m.loser_next_match_id:
|
||||||
|
prereqs[m.loser_next_match_id].append(m.id)
|
||||||
|
|
||||||
|
# 3. Calculate depths
|
||||||
def get_depth(m_id):
|
def get_depth(m_id):
|
||||||
if m_id not in match_map:
|
if m_id not in match_map:
|
||||||
return 0
|
return 0
|
||||||
if m_id in depth_cache:
|
if m_id in depth_cache:
|
||||||
return depth_cache[m_id]
|
return depth_cache[m_id]
|
||||||
|
|
||||||
m = match_map[m_id]
|
m = match_map[m_id]
|
||||||
child_depths = [0]
|
child_depths = [0]
|
||||||
if m.winner_next_match_id:
|
if m.winner_next_match_id:
|
||||||
child_depths.append(get_depth(m.winner_next_match_id))
|
child_depths.append(get_depth(m.winner_next_match_id))
|
||||||
if m.loser_next_match_id:
|
if m.loser_next_match_id:
|
||||||
child_depths.append(get_depth(m.loser_next_match_id))
|
child_depths.append(get_depth(m.loser_next_match_id))
|
||||||
|
|
||||||
depth = 1 + max(child_depths)
|
depth = 1 + max(child_depths)
|
||||||
depth_cache[m_id] = depth
|
depth_cache[m_id] = depth
|
||||||
return depth
|
return depth
|
||||||
|
|
||||||
for m in matches:
|
for m in all_matches:
|
||||||
get_depth(m.id)
|
get_depth(m.id)
|
||||||
|
|
||||||
tournament_start = (
|
# 4. Initialize timers & lock finished matches
|
||||||
t.timestamp.replace(tzinfo=None) if t.timestamp.tzinfo else t.timestamp
|
all_courts: set[models.Court] = set()
|
||||||
)
|
court_sharing_count = defaultdict(
|
||||||
court_timers = {c.id: tournament_start for c in t.courts}
|
int
|
||||||
|
) # NEW: Track how highly contested each court is
|
||||||
|
|
||||||
|
for t_item in tournaments:
|
||||||
|
for c in t_item.courts:
|
||||||
|
all_courts.add(c)
|
||||||
|
court_sharing_count[c.id] += 1 # NEW
|
||||||
|
|
||||||
|
court_timers = {c.id: start_of_day for c in all_courts}
|
||||||
planned_finish_times = {}
|
planned_finish_times = {}
|
||||||
unscheduled = list(matches)
|
unscheduled: list[models.Match] = []
|
||||||
|
|
||||||
loop_limit = len(matches) * 2
|
for m in all_matches:
|
||||||
|
if m.status == MatchStatus.FINISHED and m.start_time and m.court_id:
|
||||||
|
fin = m.start_time + timedelta(minutes=m.tournament.duration)
|
||||||
|
planned_finish_times[m.id] = fin
|
||||||
|
if court_timers.get(m.court_id, start_of_day) < fin:
|
||||||
|
court_timers[m.court_id] = fin
|
||||||
|
else:
|
||||||
|
unscheduled.append(m)
|
||||||
|
|
||||||
|
tournament_match_counts = {t_item.id: 0 for t_item in tournaments}
|
||||||
|
|
||||||
|
# 5. Global Interleaving Schedule Loop
|
||||||
|
loop_limit = len(unscheduled) * 3
|
||||||
while unscheduled and loop_limit > 0:
|
while unscheduled and loop_limit > 0:
|
||||||
loop_limit -= 1
|
loop_limit -= 1
|
||||||
best_court_id = min(court_timers, key=lambda k: court_timers[k])
|
|
||||||
|
best_court_id = min(
|
||||||
|
court_timers.keys(), key=lambda k: (court_timers[k], court_sharing_count[k])
|
||||||
|
)
|
||||||
current_time = court_timers[best_court_id]
|
current_time = court_timers[best_court_id]
|
||||||
|
|
||||||
ready: list[models.Match] = []
|
ready: list[models.Match] = []
|
||||||
for m in unscheduled:
|
for m in unscheduled:
|
||||||
|
if best_court_id not in [c.id for c in m.tournament.courts]:
|
||||||
|
continue
|
||||||
|
|
||||||
is_ready = True
|
is_ready = True
|
||||||
max_prereq_time = tournament_start
|
max_prereq_time = (
|
||||||
|
m.tournament.timestamp.replace(tzinfo=None)
|
||||||
|
if m.tournament.timestamp.tzinfo
|
||||||
|
else m.tournament.timestamp
|
||||||
|
)
|
||||||
|
|
||||||
for p_id in prereqs[m.id]:
|
for p_id in prereqs[m.id]:
|
||||||
if p_id not in planned_finish_times:
|
if p_id not in planned_finish_times:
|
||||||
is_ready = False
|
is_ready = False
|
||||||
@@ -143,27 +192,41 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
|
|
||||||
if ready:
|
if ready:
|
||||||
ready.sort(
|
ready.sort(
|
||||||
key=lambda x: (-depth_cache[x.id], x.round_number, x.match_number)
|
key=lambda x: (
|
||||||
|
len(x.tournament.courts),
|
||||||
|
tournament_match_counts[x.tournament_id],
|
||||||
|
-depth_cache[x.id],
|
||||||
|
x.round_number,
|
||||||
|
x.match_number,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
cand = ready[0]
|
cand = ready[0]
|
||||||
|
|
||||||
cand.court_id = best_court_id
|
cand.court_id = best_court_id
|
||||||
cand.start_time = current_time
|
cand.start_time = current_time
|
||||||
|
|
||||||
fin = current_time + timedelta(minutes=t.duration)
|
fin = current_time + timedelta(minutes=cand.tournament.duration)
|
||||||
planned_finish_times[cand.id] = fin
|
planned_finish_times[cand.id] = fin
|
||||||
court_timers[best_court_id] = fin
|
court_timers[best_court_id] = fin
|
||||||
|
tournament_match_counts[cand.tournament_id] += 1
|
||||||
unscheduled.remove(cand)
|
unscheduled.remove(cand)
|
||||||
else:
|
else:
|
||||||
next_wake = None
|
next_wake = None
|
||||||
for m in unscheduled:
|
for m in unscheduled:
|
||||||
|
if best_court_id not in [c.id for c in m.tournament.courts]:
|
||||||
|
continue
|
||||||
is_ready = True
|
is_ready = True
|
||||||
max_prereq_time = tournament_start
|
max_prereq_time = (
|
||||||
|
m.tournament.timestamp.replace(tzinfo=None)
|
||||||
|
if m.tournament.timestamp.tzinfo
|
||||||
|
else m.tournament.timestamp
|
||||||
|
)
|
||||||
for p_id in prereqs[m.id]:
|
for p_id in prereqs[m.id]:
|
||||||
if p_id not in planned_finish_times:
|
if p_id not in planned_finish_times:
|
||||||
is_ready = False
|
is_ready = False
|
||||||
break
|
break
|
||||||
max_prereq_time = max(max_prereq_time, planned_finish_times[p_id])
|
max_prereq_time = max(max_prereq_time, planned_finish_times[p_id])
|
||||||
|
|
||||||
if is_ready and max_prereq_time > current_time:
|
if is_ready and max_prereq_time > current_time:
|
||||||
if next_wake is None or max_prereq_time < next_wake:
|
if next_wake is None or max_prereq_time < next_wake:
|
||||||
next_wake = max_prereq_time
|
next_wake = max_prereq_time
|
||||||
@@ -171,22 +234,26 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
if next_wake:
|
if next_wake:
|
||||||
court_timers[best_court_id] = next_wake
|
court_timers[best_court_id] = next_wake
|
||||||
else:
|
else:
|
||||||
break
|
court_timers[best_court_id] += timedelta(minutes=5)
|
||||||
|
|
||||||
# ==========================================
|
# 6. Dynamic Referee Assignment (Runs globally across all interleaved matches!)
|
||||||
# --- DYNAMIC REFEREE ASSIGNMENT ---
|
all_matches = sorted(
|
||||||
# ==========================================
|
all_matches, key=lambda x: (x.start_time or start_of_day, x.court_id or 0)
|
||||||
all_matches = sorted(list(matches), key=lambda x: (x.start_time, x.court_id))
|
)
|
||||||
duty_counts = defaultdict(int)
|
duty_counts = defaultdict(int)
|
||||||
active_refs = []
|
active_refs = []
|
||||||
|
|
||||||
def is_busy(outcome_tuple, source_m, start, end):
|
def is_busy(outcome_tuple, source_m: models.Match, start, end):
|
||||||
next_m_id = source_m.winner_next_match_id if outcome_tuple[0] == "W" else source_m.loser_next_match_id
|
next_m_id = (
|
||||||
|
source_m.winner_next_match_id
|
||||||
|
if outcome_tuple[0] == "W"
|
||||||
|
else source_m.loser_next_match_id
|
||||||
|
)
|
||||||
if next_m_id:
|
if next_m_id:
|
||||||
next_m = match_map[next_m_id]
|
next_m = match_map[next_m_id]
|
||||||
next_start = next_m.start_time
|
next_start = next_m.start_time
|
||||||
if next_start:
|
if next_start:
|
||||||
next_end = next_start + timedelta(minutes=t.duration)
|
next_end = next_start + timedelta(minutes=next_m.tournament.duration)
|
||||||
if not (next_end <= start or next_start >= end):
|
if not (next_end <= start or next_start >= end):
|
||||||
return True
|
return True
|
||||||
for r_outcome, r_start, r_end in active_refs:
|
for r_outcome, r_start, r_end in active_refs:
|
||||||
@@ -198,29 +265,37 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
for m in all_matches:
|
for m in all_matches:
|
||||||
if not m.start_time:
|
if not m.start_time:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m_start = m.start_time
|
m_start = m.start_time
|
||||||
m_end = m_start + timedelta(minutes=t.duration)
|
m_end = m_start + timedelta(minutes=m.tournament.duration)
|
||||||
|
|
||||||
if m.bracket_type == BracketTypes.FINALS:
|
if m.bracket_type == BracketTypes.FINALS:
|
||||||
prev_final = next((p for p in all_matches if p.bracket_type == BracketTypes.FINALS and p.winner_next_match_id == m.id), None)
|
prev_final = next(
|
||||||
|
(
|
||||||
|
p
|
||||||
|
for p in all_matches
|
||||||
|
if p.bracket_type == BracketTypes.FINALS
|
||||||
|
and p.winner_next_match_id == m.id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
if prev_final:
|
if prev_final:
|
||||||
m.ref_team_id = prev_final.ref_team_id
|
m.ref_team_id = prev_final.ref_team_id
|
||||||
m.ref_label = prev_final.ref_label
|
m.ref_label = prev_final.ref_label
|
||||||
|
identifier = (
|
||||||
identifier = f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label
|
f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label
|
||||||
|
)
|
||||||
active_refs.append((identifier, m_start, m_end))
|
active_refs.append((identifier, m_start, m_end))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
best_outcome = None
|
best_outcome = None
|
||||||
best_score = float('inf')
|
best_score = float("inf")
|
||||||
|
|
||||||
for prev_m in all_matches:
|
for prev_m in all_matches:
|
||||||
if not prev_m.start_time:
|
if not prev_m.start_time:
|
||||||
continue
|
continue
|
||||||
prev_end = prev_m.start_time + timedelta(minutes=t.duration)
|
prev_end = prev_m.start_time + timedelta(minutes=prev_m.tournament.duration)
|
||||||
|
|
||||||
if prev_end <= m_start:
|
if prev_end <= m_start:
|
||||||
l_outcome = ("L", prev_m.id)
|
l_outcome = ("L", prev_m.id)
|
||||||
if not is_busy(l_outcome, prev_m, m_start, m_end):
|
if not is_busy(l_outcome, prev_m, m_start, m_end):
|
||||||
@@ -231,17 +306,17 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
if score < best_score:
|
if score < best_score:
|
||||||
best_score = score
|
best_score = score
|
||||||
best_outcome = (l_outcome, prev_m)
|
best_outcome = (l_outcome, prev_m)
|
||||||
|
|
||||||
w_outcome = ("W", prev_m.id)
|
w_outcome = ("W", prev_m.id)
|
||||||
if not is_busy(w_outcome, prev_m, m_start, m_end):
|
if not is_busy(w_outcome, prev_m, m_start, m_end):
|
||||||
wait_mins = (m_start - prev_end).total_seconds() / 60.0
|
wait_mins = (m_start - prev_end).total_seconds() / 60.0
|
||||||
score = (duty_counts[w_outcome] * 120) + wait_mins + 60
|
score = (duty_counts[w_outcome] * 120) + wait_mins + 60
|
||||||
if prev_m.court_id == m.court_id:
|
if prev_m.court_id == m.court_id:
|
||||||
score -= 30
|
score -= 30
|
||||||
if score < best_score:
|
if score < best_score:
|
||||||
best_score = score
|
best_score = score
|
||||||
best_outcome = (w_outcome, prev_m)
|
best_outcome = (w_outcome, prev_m)
|
||||||
|
|
||||||
if best_outcome:
|
if best_outcome:
|
||||||
outcome_tuple, prev_m = best_outcome
|
outcome_tuple, prev_m = best_outcome
|
||||||
duty_counts[outcome_tuple] += 1
|
duty_counts[outcome_tuple] += 1
|
||||||
@@ -249,20 +324,18 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
role = "Winner" if outcome_tuple[0] == "W" else "Loser"
|
role = "Winner" if outcome_tuple[0] == "W" else "Loser"
|
||||||
m.ref_label = f"{role} of #{prev_m.match_number}"
|
m.ref_label = f"{role} of #{prev_m.match_number}"
|
||||||
else:
|
else:
|
||||||
m.ref_label = "Staff / Volunteers"
|
m.ref_label = "Staff / Volunteers"
|
||||||
for future_m in all_matches:
|
for future_m in all_matches:
|
||||||
if future_m.start_time and future_m.start_time >= m_end:
|
if future_m.start_time and future_m.start_time >= m_end:
|
||||||
assigned = False
|
assigned = False
|
||||||
|
|
||||||
for t_id in [future_m.p1_team_id, future_m.p2_team_id]:
|
for t_id in [future_m.p1_team_id, future_m.p2_team_id]:
|
||||||
if t_id:
|
if t_id:
|
||||||
team_identifier = f"TEAM_{t_id}"
|
team_identifier = f"TEAM_{t_id}"
|
||||||
|
|
||||||
is_team_busy = any(
|
is_team_busy = any(
|
||||||
r_outcome == team_identifier and not (r_end <= m_start or r_start >= m_end)
|
r_outcome == team_identifier
|
||||||
|
and not (r_end <= m_start or r_start >= m_end)
|
||||||
for r_outcome, r_start, r_end in active_refs
|
for r_outcome, r_start, r_end in active_refs
|
||||||
)
|
)
|
||||||
|
|
||||||
if not is_team_busy:
|
if not is_team_busy:
|
||||||
m.ref_team_id = t_id
|
m.ref_team_id = t_id
|
||||||
m.ref_label = None
|
m.ref_label = None
|
||||||
@@ -271,7 +344,6 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
break
|
break
|
||||||
if assigned:
|
if assigned:
|
||||||
break
|
break
|
||||||
# ----------------------------------
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -355,7 +427,10 @@ def undo_advancement(db: Session, match: models.Match):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for m in match.tournament.matches:
|
for m in match.tournament.matches:
|
||||||
if m.ref_label == f"Loser of #{match.match_number}" or m.ref_label == f"Winner of #{match.match_number}":
|
if (
|
||||||
|
m.ref_label == f"Loser of #{match.match_number}"
|
||||||
|
or m.ref_label == f"Winner of #{match.match_number}"
|
||||||
|
):
|
||||||
m.ref_team_id = None
|
m.ref_team_id = None
|
||||||
db.add(m)
|
db.add(m)
|
||||||
|
|
||||||
@@ -385,5 +460,5 @@ def undo_advancement(db: Session, match: models.Match):
|
|||||||
clear_from_next(match.winner_next_match, match.winner_next_match_slot)
|
clear_from_next(match.winner_next_match, match.winner_next_match_slot)
|
||||||
if match.loser_next_match and loser_id:
|
if match.loser_next_match and loser_id:
|
||||||
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
|
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .database import Base, engine
|
from .database import Base, engine
|
||||||
from .routes import auth, tournaments, websocket
|
from .routes import auth, tournaments, websocket, courts
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -15,6 +15,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="Tournament Bracket API", lifespan=lifespan, root_path="/api")
|
app = FastAPI(title="Tournament Bracket API", lifespan=lifespan, root_path="/api")
|
||||||
app.include_router(tournaments.router)
|
app.include_router(tournaments.router)
|
||||||
|
app.include_router(courts.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(websocket.router)
|
app.include_router(websocket.router)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
+68
-26
@@ -2,7 +2,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import JSON, DateTime
|
from sqlalchemy import JSON, DateTime, Table, Column
|
||||||
from sqlalchemy import Enum as SqlEnum
|
from sqlalchemy import Enum as SqlEnum
|
||||||
from sqlalchemy import ForeignKey, Integer, String
|
from sqlalchemy import ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
@@ -10,6 +10,33 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from .constants import MatchStatus, TournamentTypes, BracketTypes
|
from .constants import MatchStatus, TournamentTypes, BracketTypes
|
||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
|
tournament_courts = Table(
|
||||||
|
"tournament_courts",
|
||||||
|
Base.metadata,
|
||||||
|
Column(
|
||||||
|
"tournament_id",
|
||||||
|
String(8),
|
||||||
|
ForeignKey("tournaments.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
"court_id",
|
||||||
|
Integer,
|
||||||
|
ForeignKey("courts.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Court(Base):
|
||||||
|
__tablename__ = "courts"
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
||||||
|
|
||||||
|
tournaments: Mapped[list["Tournament"]] = relationship(
|
||||||
|
"Tournament", secondary=tournament_courts, back_populates="courts"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Tournament(Base):
|
class Tournament(Base):
|
||||||
__tablename__ = "tournaments"
|
__tablename__ = "tournaments"
|
||||||
@@ -25,7 +52,7 @@ class Tournament(Base):
|
|||||||
"Team", back_populates="tournament", cascade="all, delete-orphan"
|
"Team", back_populates="tournament", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
courts: Mapped[list["Court"]] = relationship(
|
courts: Mapped[list["Court"]] = relationship(
|
||||||
"Court", back_populates="tournament", cascade="all, delete-orphan"
|
"Court", secondary=tournament_courts, back_populates="tournaments"
|
||||||
)
|
)
|
||||||
|
|
||||||
matches: Mapped[list["Match"]] = relationship(
|
matches: Mapped[list["Match"]] = relationship(
|
||||||
@@ -50,15 +77,6 @@ class Team(Base):
|
|||||||
tournament: Mapped["Tournament"] = relationship(back_populates="teams")
|
tournament: Mapped["Tournament"] = relationship(back_populates="teams")
|
||||||
|
|
||||||
|
|
||||||
class Court(Base):
|
|
||||||
__tablename__ = "courts"
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
|
||||||
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
|
|
||||||
|
|
||||||
tournament: Mapped["Tournament"] = relationship(back_populates="courts")
|
|
||||||
|
|
||||||
|
|
||||||
class Match(Base):
|
class Match(Base):
|
||||||
__tablename__ = "matches"
|
__tablename__ = "matches"
|
||||||
|
|
||||||
@@ -70,32 +88,56 @@ class Match(Base):
|
|||||||
|
|
||||||
bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes))
|
bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes))
|
||||||
|
|
||||||
court_id: Mapped[Optional[int]] = mapped_column(ForeignKey("courts.id"), nullable=True)
|
court_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
ForeignKey("courts.id"), nullable=True
|
||||||
|
)
|
||||||
start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
status: Mapped[MatchStatus] = mapped_column(SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED)
|
status: Mapped[MatchStatus] = mapped_column(
|
||||||
|
SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED
|
||||||
|
)
|
||||||
|
|
||||||
p1_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
p1_team_id: Mapped[Optional[int]] = mapped_column(
|
||||||
p2_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
ForeignKey("teams.id"), nullable=True
|
||||||
|
)
|
||||||
ref_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
p2_team_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
ForeignKey("teams.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
ref_team_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
ForeignKey("teams.id"), nullable=True
|
||||||
|
)
|
||||||
ref_label: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
ref_label: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||||
winner_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
winner_team_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
ForeignKey("teams.id"), nullable=True
|
||||||
winner_next_match_id: Mapped[Optional[str]] = mapped_column(ForeignKey("matches.id"), nullable=True)
|
)
|
||||||
winner_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
||||||
|
|
||||||
loser_next_match_id: Mapped[Optional[str]] = mapped_column(ForeignKey("matches.id"), nullable=True)
|
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
ForeignKey("matches.id"), nullable=True
|
||||||
|
)
|
||||||
|
winner_next_match_slot: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
loser_next_match_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
ForeignKey("matches.id"), nullable=True
|
||||||
|
)
|
||||||
loser_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
loser_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
tournament: Mapped["Tournament"] = relationship(back_populates="matches")
|
tournament: Mapped["Tournament"] = relationship(back_populates="matches")
|
||||||
court: Mapped[Optional["Court"]] = relationship()
|
court: Mapped[Optional["Court"]] = relationship()
|
||||||
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
|
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
|
||||||
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
|
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
|
||||||
|
|
||||||
ref_team: Mapped[Optional["Team"]] = relationship("Team", foreign_keys=[ref_team_id])
|
|
||||||
|
|
||||||
winner_next_match: Mapped["Match"] = relationship("Match", remote_side=[id], foreign_keys=[winner_next_match_id])
|
ref_team: Mapped[Optional["Team"]] = relationship(
|
||||||
loser_next_match: Mapped["Match"] = relationship("Match", remote_side=[id], foreign_keys=[loser_next_match_id])
|
"Team", foreign_keys=[ref_team_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
winner_next_match: Mapped["Match"] = relationship(
|
||||||
|
"Match", remote_side=[id], foreign_keys=[winner_next_match_id]
|
||||||
|
)
|
||||||
|
loser_next_match: Mapped["Match"] = relationship(
|
||||||
|
"Match", remote_side=[id], foreign_keys=[loser_next_match_id]
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# backend/app/routes/courts.py
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
from .. import crud, schemas, models
|
||||||
|
from ..constants import SUCCESS
|
||||||
|
from ..core.auth import get_admin_user
|
||||||
|
from ..database import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/courts", tags=["Courts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[schemas.CourtSchema])
|
||||||
|
def get_all_courts(db: Session = Depends(get_db)):
|
||||||
|
"""Public route to list all global courts"""
|
||||||
|
return db.query(models.Court).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=schemas.CourtSchema)
|
||||||
|
def create_global_court(
|
||||||
|
data: schemas.CourtCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: dict = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
"""Admin route to register a new physical court"""
|
||||||
|
return crud.create_court(db, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{court_id}")
|
||||||
|
def delete_global_court(
|
||||||
|
court_id: int, db: Session = Depends(get_db), user: dict = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
success = crud.delete_court(db, court_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(404, "Court not found")
|
||||||
|
return SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{court_id}/schedule")
|
||||||
|
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
|
||||||
|
court = db.query(models.Court).filter(models.Court.id == court_id).first()
|
||||||
|
if not court:
|
||||||
|
raise HTTPException(404, "Court not found")
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
matches = (
|
||||||
|
db.query(models.Match)
|
||||||
|
.join(models.Tournament)
|
||||||
|
.filter(models.Match.court_id == court_id)
|
||||||
|
.filter(
|
||||||
|
models.Tournament.timestamp >= datetime.combine(today, datetime.min.time())
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
models.Tournament.timestamp < datetime.combine(today, datetime.max.time())
|
||||||
|
)
|
||||||
|
.order_by(models.Match.start_time)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule = []
|
||||||
|
for m in matches:
|
||||||
|
schedule.append(
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"tournament_id": m.tournament_id,
|
||||||
|
"tournament_name": m.tournament.name,
|
||||||
|
"time": m.start_time.strftime("%H:%M") if m.start_time else "TBD",
|
||||||
|
"status": m.status,
|
||||||
|
"match_number": m.match_number,
|
||||||
|
"p1": m.p1_team.name if m.p1_team else "TBD",
|
||||||
|
"p2": m.p2_team.name if m.p2_team else "TBD",
|
||||||
|
"p1_sets": sum(1 for s in m.sets if s["p1"] > s["p2"]) if m.sets else 0,
|
||||||
|
"p2_sets": sum(1 for s in m.sets if s["p2"] > s["p1"]) if m.sets else 0,
|
||||||
|
"ref_name": m.ref_team.name if m.ref_team else m.ref_label,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"court": court.name, "matches": schedule}
|
||||||
@@ -3,4 +3,4 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
|
router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
|
||||||
|
|
||||||
from . import tournaments, settings, teams, courts, matches, report
|
from . import tournaments, settings, teams, matches, report
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# backend/app/routes/tournaments/courts.py
|
|
||||||
from fastapi import Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from ... import crud, schemas
|
|
||||||
from ...constants import SUCCESS
|
|
||||||
from ...core.auth import get_admin_user
|
|
||||||
from ...core.websocket_manager import send_ws_update
|
|
||||||
from ...database import get_db
|
|
||||||
from . import router
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}/courts", response_model=list[schemas.CourtSchema])
|
|
||||||
def get_courts(id: str, db: Session = Depends(get_db)):
|
|
||||||
return crud.get_courts(db, id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/courts", response_model=schemas.CourtSchema)
|
|
||||||
async def create_court(
|
|
||||||
id: str,
|
|
||||||
court: schemas.CourtCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: dict = Depends(get_admin_user),
|
|
||||||
):
|
|
||||||
new_court = crud.create_court(db, id, court)
|
|
||||||
if not new_court:
|
|
||||||
raise HTTPException(404, "Tournament not found")
|
|
||||||
|
|
||||||
await send_ws_update(id)
|
|
||||||
return new_court
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{id}/courts", response_model=schemas.TournamentDetail)
|
|
||||||
async def update_courts(
|
|
||||||
id: str,
|
|
||||||
courts: list[str],
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: dict = Depends(get_admin_user),
|
|
||||||
):
|
|
||||||
t = crud.update_tournament_courts(db, id, courts)
|
|
||||||
if not t:
|
|
||||||
raise HTTPException(404, "Not found")
|
|
||||||
|
|
||||||
await send_ws_update(id)
|
|
||||||
return t
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{id}/courts/{court_id}")
|
|
||||||
async def delete_court(
|
|
||||||
id: str,
|
|
||||||
court_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: dict = Depends(get_admin_user),
|
|
||||||
):
|
|
||||||
success = crud.delete_court(db, id, court_id)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(404, "Court or Tournament not found")
|
|
||||||
|
|
||||||
await send_ws_update(id)
|
|
||||||
return SUCCESS
|
|
||||||
@@ -60,3 +60,17 @@ async def delete_tournament(
|
|||||||
await send_ws_update(id)
|
await send_ws_update(id)
|
||||||
|
|
||||||
return SUCCESS
|
return SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{id}/courts", response_model=schemas.TournamentOut)
|
||||||
|
def update_tournament_courts(
|
||||||
|
id: str,
|
||||||
|
court_ids: list[int],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: dict = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
"""Updates the global courts assigned to a specific tournament"""
|
||||||
|
t = crud.update_tournament_courts(db, id, court_ids)
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "Tournament not found")
|
||||||
|
return t
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class MatchOut(BaseModel):
|
|||||||
p1_team_id: int | None = None
|
p1_team_id: int | None = None
|
||||||
p2_team_id: int | None = None
|
p2_team_id: int | None = None
|
||||||
winner_team_id: int | None = None
|
winner_team_id: int | None = None
|
||||||
|
|
||||||
ref_team_id: int | None = None
|
ref_team_id: int | None = None
|
||||||
ref_label: str | None = None
|
ref_label: str | None = None
|
||||||
ref_team: TeamSchema | None = None
|
ref_team: TeamSchema | None = None
|
||||||
@@ -71,7 +71,7 @@ class TournamentCreate(BaseModel):
|
|||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
duration: int
|
duration: int
|
||||||
teams: list[str]
|
teams: list[str]
|
||||||
courts: list[str]
|
courts: list[int]
|
||||||
|
|
||||||
|
|
||||||
class TournamentUpdate(BaseModel):
|
class TournamentUpdate(BaseModel):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.database import Base, get_db
|
|||||||
|
|
||||||
# Import your app and models
|
# Import your app and models
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app import models
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -43,6 +44,15 @@ def db(prepare_db) -> Generator[Session, None, None]:
|
|||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_db(db):
|
||||||
|
db.query(models.Match).delete()
|
||||||
|
db.query(models.Team).delete()
|
||||||
|
db.query(models.Court).delete()
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
||||||
def override_get_db():
|
def override_get_db():
|
||||||
@@ -85,13 +95,20 @@ def auth_headers(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def valid_tournament_payload():
|
async def valid_tournament_payload(db):
|
||||||
|
# 1. Create global courts first
|
||||||
|
c1 = models.Court(name="Plan 1")
|
||||||
|
c2 = models.Court(name="Plan 2")
|
||||||
|
db.add_all([c1, c2])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2. Return payload with IDs
|
||||||
return {
|
return {
|
||||||
"name": "Test Tournament",
|
"name": "Test Tournament",
|
||||||
"code": "1234",
|
"code": "1234",
|
||||||
"type": "Double",
|
"type": "Double",
|
||||||
"timestamp": "2024-01-01T10:00:00",
|
"timestamp": "2026-05-24T11:00:00",
|
||||||
"duration": 15,
|
"duration": 15,
|
||||||
"teams": ["Team A", "Team B", "Team C", "Team D"],
|
"teams": ["Team A", "Team B", "Team C", "Team D"],
|
||||||
"courts": ["Court 1", "Court 2"],
|
"courts": [c1.id, c2.id],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ async def test_manage_teams(
|
|||||||
|
|
||||||
# Verify the teams are actually in the first match
|
# Verify the teams are actually in the first match
|
||||||
first_match = final_matches[0]
|
first_match = final_matches[0]
|
||||||
print(first_match)
|
assert first_match["p1_team_id"] is not None
|
||||||
|
assert first_match["p2_team_id"] is not None
|
||||||
|
|
||||||
p1_name = first_match["p1_team_id"]
|
p1_name = first_match["p1_team_id"]
|
||||||
p2_name = first_match["p2_team_id"]
|
p2_name = first_match["p2_team_id"]
|
||||||
|
|
||||||
@@ -60,21 +62,21 @@ async def test_manage_courts(
|
|||||||
)
|
)
|
||||||
t_id = res.json()["id"]
|
t_id = res.json()["id"]
|
||||||
|
|
||||||
# Get initial courts
|
# 1. Get initial courts from the tournament detail
|
||||||
courts_res = await client.get(f"/tournaments/{t_id}")
|
courts_res = await client.get(f"/tournaments/{t_id}")
|
||||||
initial_courts = courts_res.json()["courts"]
|
initial_courts = courts_res.json()["courts"]
|
||||||
assert len(initial_courts) == 2
|
assert len(initial_courts) == 2
|
||||||
|
|
||||||
# Delete
|
# 2. Extract the ID from the list so the variable is defined!
|
||||||
court_id = initial_courts[0]["id"]
|
court_to_delete_id = initial_courts[0]["id"]
|
||||||
del_res = await client.delete(
|
|
||||||
f"/tournaments/{t_id}/courts/{court_id}", headers=auth_headers
|
# 3. Now use that variable in your delete call
|
||||||
)
|
del_res = await client.delete(f"/courts/{court_to_delete_id}", headers=auth_headers)
|
||||||
assert del_res.status_code == 200
|
assert del_res.status_code == 200
|
||||||
|
|
||||||
# Create
|
# CREATE: Change this from a tournament-specific route to the global one
|
||||||
create_res = await client.post(
|
create_res = await client.post(
|
||||||
f"/tournaments/{t_id}/courts", json={"name": "New Court"}, headers=auth_headers
|
"/courts", json={"name": "New Court"}, headers=auth_headers # GLOBAL ROUTE
|
||||||
)
|
)
|
||||||
assert create_res.status_code == 200
|
assert create_res.status_code == 200
|
||||||
assert create_res.json()["name"] == "New Court"
|
assert create_res.json()["name"] == "New Court"
|
||||||
|
|||||||
Generated
+1033
-985
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
|||||||
import Layout from './components/Layout/Layout';
|
import Layout from './components/Layout/Layout';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
|
import Courts from './pages/Courts';
|
||||||
import Tournament from './pages/Tournament';
|
import Tournament from './pages/Tournament';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -34,6 +35,7 @@ export default function App() {
|
|||||||
{/* Main App Layout */}
|
{/* Main App Layout */}
|
||||||
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
|
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/courts" element={<Courts />} />
|
||||||
<Route path="/tournaments/:id" element={<Tournament />} />
|
<Route path="/tournaments/:id" element={<Tournament />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M431.789,47.747H272.387V32.384c0-9.043-7.331-16.383-16.383-16.383s-16.383,7.34-16.383,16.383v15.362H80.211 C35.988,47.747,0,83.734,0,127.967v256.067c0,44.232,35.988,80.22,80.211,80.22h159.41v15.354 c0,9.052,7.331,16.392,16.383,16.392c9.043,0,16.374-7.339,16.383-16.392v-15.354h159.401c44.232,0,80.211-35.988,80.211-80.22 V127.967C512,83.734,476.012,47.747,431.789,47.747z M80.211,431.496c-26.172,0-47.454-21.291-47.454-47.454V127.976 c0-26.172,21.291-47.454,47.454-47.454v-0.009h159.41v350.983H80.211z M479.243,384.042c0,26.172-21.291,47.454-47.454,47.454 H272.387V80.513h159.401c26.172,0,47.454,21.291,47.454,47.454V384.042z"/>
|
||||||
|
<path d="M351.738,93.994c-9.052,0-16.383,7.331-16.383,16.383v291.238c0,9.052,7.331,16.392,16.383,16.392 c9.044,0,16.383-7.34,16.383-16.392V110.377C368.121,101.333,360.79,93.994,351.738,93.994z"/>
|
||||||
|
<path d="M160.271,93.994c-9.052,0-16.383,7.331-16.383,16.383v291.238c0,9.052,7.331,16.392,16.383,16.392 c9.043,0,16.374-7.34,16.383-16.392V110.377C176.654,101.333,169.324,93.994,160.271,93.994z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,6 +1,7 @@
|
|||||||
// frontend/src/components/Dashboard/DashCard.tsx
|
// frontend/src/components/Dashboard/DashCard.tsx
|
||||||
|
|
||||||
import { MapPin, SlidersHorizontal, Users } from 'lucide-react';
|
import { SlidersHorizontal, Users } from 'lucide-react';
|
||||||
|
import CourtIcon from '../../assets/court.svg?react';
|
||||||
|
|
||||||
interface TournamentSummary {
|
interface TournamentSummary {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -45,7 +46,7 @@ export default function DashCard({ t, isAdmin, onSelect, onEdit }: DashCardProps
|
|||||||
{t.team_count} Teams
|
{t.team_count} Teams
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 font-medium">
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
<MapPin size={16} className="text-orange-500" />
|
<CourtIcon width={16} height={16} className="text-orange-500" />
|
||||||
{t.court_count} Courts
|
{t.court_count} Courts
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
// frontend/src/components/Forms/TournamentForm.tsx
|
// frontend/src/components/Forms/TournamentForm.tsx
|
||||||
|
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, Plus } from 'lucide-react';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface Court {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TournamentSettings {
|
interface TournamentSettings {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -11,7 +16,7 @@ interface TournamentSettings {
|
|||||||
type: string;
|
type: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
courts: { name?: string }[];
|
courts: Court[];
|
||||||
teams: { name?: string }[];
|
teams: { name?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,33 +28,55 @@ interface TournamentFormProps {
|
|||||||
|
|
||||||
export default function TournamentForm({ tournamentId, onSuccess, onDelete }: TournamentFormProps) {
|
export default function TournamentForm({ tournamentId, onSuccess, onDelete }: TournamentFormProps) {
|
||||||
const [initialData, setInitialData] = useState<TournamentSettings | null>(null);
|
const [initialData, setInitialData] = useState<TournamentSettings | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(!!tournamentId);
|
const [globalCourts, setGlobalCourts] = useState<Court[]>([]);
|
||||||
|
const [selectedCourts, setSelectedCourts] = useState<number[]>([]);
|
||||||
|
const [newCourtName, setNewCourtName] = useState('');
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tournamentId) {
|
const fetchDependencies = async () => {
|
||||||
setInitialData(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
|
||||||
try {
|
try {
|
||||||
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
|
const courtsRes = await api.get<Court[]>('/courts');
|
||||||
setInitialData(data);
|
setGlobalCourts(courtsRes);
|
||||||
|
|
||||||
|
if (tournamentId) {
|
||||||
|
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
|
||||||
|
setInitialData(data);
|
||||||
|
setSelectedCourts(data.courts.map(c => c.id));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setError("Failed to load tournament settings.");
|
setError("Failed to load tournament data.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchSettings();
|
void fetchDependencies();
|
||||||
}, [tournamentId]);
|
}, [tournamentId]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleAddCourt = async () => {
|
||||||
|
if (!newCourtName.trim()) return;
|
||||||
|
try {
|
||||||
|
const added = await api.post<Court>('/courts', { name: newCourtName.trim() });
|
||||||
|
setGlobalCourts([...globalCourts, added]);
|
||||||
|
setSelectedCourts([...selectedCourts, added.id]);
|
||||||
|
setNewCourtName('');
|
||||||
|
} catch {
|
||||||
|
setError("Failed to create new court.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCourt = (id: number) => {
|
||||||
|
setSelectedCourts(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -57,7 +84,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
|||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
const rawTeams = formData.get('teams') as string;
|
const rawTeams = formData.get('teams') as string;
|
||||||
const rawCourts = formData.get('courts') as string;
|
|
||||||
const date = formData.get('date') as string;
|
const date = formData.get('date') as string;
|
||||||
const startTime = formData.get('start_time') as string;
|
const startTime = formData.get('start_time') as string;
|
||||||
const typeRaw = formData.get('type') as string;
|
const typeRaw = formData.get('type') as string;
|
||||||
@@ -66,7 +92,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
|||||||
const code = formData.get('code') as string;
|
const code = formData.get('code') as string;
|
||||||
|
|
||||||
const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0);
|
const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0);
|
||||||
const courts = rawCourts.split(',').map(c => c.trim()).filter(c => c.length > 0);
|
|
||||||
|
|
||||||
if (teams.length < 2) {
|
if (teams.length < 2) {
|
||||||
setError("At least 2 teams required.");
|
setError("At least 2 teams required.");
|
||||||
@@ -74,6 +99,12 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedCourts.length === 0) {
|
||||||
|
setError("Please select at least one court.");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = `${date}T${startTime}:00`;
|
const timestamp = `${date}T${startTime}:00`;
|
||||||
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
|
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
|
||||||
const parsedDuration = parseInt(duration, 10);
|
const parsedDuration = parseInt(duration, 10);
|
||||||
@@ -81,39 +112,25 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
|||||||
try {
|
try {
|
||||||
if (tournamentId) {
|
if (tournamentId) {
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
name,
|
name, code, type: formattedType, timestamp, duration: parsedDuration
|
||||||
code,
|
|
||||||
type: formattedType,
|
|
||||||
timestamp,
|
|
||||||
duration: parsedDuration
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
api.patch(`/tournaments/${tournamentId}`, basePayload),
|
api.patch(`/tournaments/${tournamentId}`, basePayload),
|
||||||
api.patch(`/tournaments/${tournamentId}/teams`, teams),
|
api.patch(`/tournaments/${tournamentId}/teams`, teams),
|
||||||
api.patch(`/tournaments/${tournamentId}/courts`, courts)
|
api.patch(`/tournaments/${tournamentId}/courts`, selectedCourts)
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
const fullPayload = {
|
const fullPayload = {
|
||||||
name,
|
name, code, type: formattedType, timestamp, duration: parsedDuration,
|
||||||
code,
|
teams, courts: selectedCourts
|
||||||
type: formattedType,
|
|
||||||
timestamp,
|
|
||||||
duration: parsedDuration,
|
|
||||||
teams,
|
|
||||||
courts
|
|
||||||
};
|
};
|
||||||
await api.post('/tournaments', fullPayload);
|
await api.post('/tournaments', fullPayload);
|
||||||
}
|
}
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
const error = err as { detail?: string | Array<{ loc: string[]; msg: string }> };
|
setError("Error saving tournament");
|
||||||
if (Array.isArray(error.detail)) {
|
|
||||||
setError(error.detail.map((e) => `${e.loc.join('.')}: ${e.msg}`).join(', '));
|
|
||||||
} else {
|
|
||||||
setError(typeof error.detail === 'string' ? error.detail : "Error saving tournament");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -147,35 +164,17 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
|
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
|
||||||
<input
|
<input name="name" defaultValue={initialData?.name} required placeholder="My Awesome Tournament" autoFocus className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||||
name="name"
|
|
||||||
defaultValue={initialData?.name}
|
|
||||||
required
|
|
||||||
placeholder="My Awesome Tournament"
|
|
||||||
autoFocus
|
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
|
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
|
||||||
<input
|
<input name="code" defaultValue={initialData?.code} required placeholder="••••" autoComplete="off" className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||||
name="code"
|
|
||||||
defaultValue={initialData?.code}
|
|
||||||
required
|
|
||||||
placeholder="••••"
|
|
||||||
autoComplete="off"
|
|
||||||
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
|
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
|
||||||
<select
|
<select name="type" defaultValue={initialData?.type?.toLowerCase() || "double"} className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white">
|
||||||
name="type"
|
|
||||||
defaultValue={initialData?.type?.toLowerCase() || "double"}
|
|
||||||
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="double">Double Elimination</option>
|
<option value="double">Double Elimination</option>
|
||||||
<option value="single">Single Elimination</option>
|
<option value="single">Single Elimination</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -185,43 +184,50 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
|||||||
<div className="grid grid-cols-7 gap-4">
|
<div className="grid grid-cols-7 gap-4">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
|
||||||
<input
|
<input type="number" name="duration" defaultValue={initialData?.duration || 30} min="0" className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||||
type="number"
|
|
||||||
name="duration"
|
|
||||||
defaultValue={initialData?.duration || 30}
|
|
||||||
min="0"
|
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
|
||||||
<input
|
<input type="time" name="start_time" defaultValue={defaultTime} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||||
type="time"
|
|
||||||
name="start_time"
|
|
||||||
defaultValue={defaultTime}
|
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label>
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label>
|
||||||
<input
|
<input type="date" name="date" defaultValue={defaultDate} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||||
type="date"
|
|
||||||
name="date"
|
|
||||||
defaultValue={defaultDate}
|
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* --- NEW: GLOBAL COURT SELECTOR --- */}
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase">Courts</label>
|
<div className="p-3 bg-zinc-50 dark:bg-zinc-900/50 rounded-lg border border-zinc-200 dark:border-zinc-800">
|
||||||
<input
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-2 block">Venue Courts</label>
|
||||||
name="courts"
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
placeholder="Center Court, Court 1"
|
{globalCourts.map(c => (
|
||||||
defaultValue={initialData?.courts?.map(c => c.name || c).join(', ')}
|
<button
|
||||||
required
|
key={c.id}
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
type="button"
|
||||||
/>
|
onClick={() => toggleCourt(c.id)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider transition ${selectedCourts.includes(c.id)
|
||||||
|
? 'bg-orange-500 text-white shadow-md'
|
||||||
|
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{globalCourts.length === 0 && <span className="text-xs italic text-zinc-400">No courts registered yet.</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newCourtName}
|
||||||
|
onChange={(e) => setNewCourtName(e.target.value)}
|
||||||
|
placeholder="Add new court..."
|
||||||
|
className="flex-1 bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded px-3 py-1 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleAddCourt} className="bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 px-3 py-1 rounded transition flex items-center">
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -239,21 +245,13 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
|||||||
|
|
||||||
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4">
|
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4">
|
||||||
{tournamentId && onDelete && (
|
{tournamentId && onDelete && (
|
||||||
<button
|
<button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline h-5 self-end">
|
||||||
type="button"
|
|
||||||
onClick={() => onDelete(tournamentId)}
|
|
||||||
className="text-red-500 text-sm hover:underline h-5 self-end"
|
|
||||||
>
|
|
||||||
Delete Tournament
|
Delete Tournament
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!tournamentId && <div className="hidden"></div>}
|
{!tournamentId && <div className="hidden"></div>}
|
||||||
|
|
||||||
<button
|
<button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50">
|
||||||
disabled={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
|
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// frontend/src/components/Layout/Navbar.tsx
|
// frontend/src/components/Layout/Navbar.tsx
|
||||||
|
|
||||||
import { Lock, LogOut, Volleyball } from 'lucide-react';
|
import { Lock, LogOut, Volleyball } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import CourtIcon from '../../assets/court.svg?react';
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,39 +12,56 @@ interface NavbarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ title, subtitle, isAuthenticated, onLogout }: NavbarProps) {
|
export default function Navbar({ title, subtitle, isAuthenticated, onLogout }: NavbarProps) {
|
||||||
return (
|
const location = useLocation();
|
||||||
<nav className="transition-colors bg-zinc-50 dark:bg-zinc-900 border-b border-zinc-300 dark:border-zinc-800 sticky top-0 z-100 px-3 sm:px-6 py-3 sm:py-4 flex justify-between items-center shadow-md shrink-0">
|
|
||||||
<Link to="/" className="flex items-center gap-2 sm:gap-4 cursor-pointer group select-none shrink-0">
|
|
||||||
<div className="p-1.5 sm:p-2.5 bg-orange-600 rounded-xl group-hover:rotate-12 transition-transform shadow-lg shadow-orange-600/30 active:scale-90">
|
|
||||||
<Volleyball className="text-white" size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter leading-none text-zinc-900 dark:text-white">VolleyManager</h1>
|
|
||||||
<p className="text-[9px] font-black text-zinc-500 dark:text-zinc-400 uppercase tracking-widest mt-0.5">Tournament Ops</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="transition-colors absolute left-1/2 -translate-x-1/2 text-center pointer-events-none w-full max-w-35 xs:max-w-[180px] sm:max-w-100">
|
return (
|
||||||
<div className="font-black uppercase text-[10px] sm:text-sm tracking-widest sm:tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1">
|
<nav className="bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 px-4 py-3 flex items-center justify-between shadow-sm sticky top-0 z-50">
|
||||||
|
|
||||||
|
{/* Left: Logo & Main Navigation */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Link to="/" className="flex items-center gap-2 shrink-0">
|
||||||
|
<div className="p-2 bg-orange-600 rounded-xl shadow-lg shadow-orange-600/20">
|
||||||
|
<Volleyball className="text-white" size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<h1 className="text-lg font-black text-zinc-900 dark:text-white leading-none">VolleyManager</h1>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link to="/courts" className="md:hidden p-2 text-zinc-600 dark:text-zinc-400 bg-zinc-100 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-lg">
|
||||||
|
<CourtIcon width={20} height={20} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
|
||||||
|
<Link to="/" className={`px-4 py-1.5 rounded-md text-xs font-black uppercase tracking-wider transition ${location.pathname === '/' ? 'bg-white dark:bg-zinc-700 shadow-sm text-orange-600' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>
|
||||||
|
Tournaments
|
||||||
|
</Link>
|
||||||
|
<Link to="/courts" className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-xs font-black uppercase tracking-wider transition ${location.pathname === '/courts' ? 'bg-orange-600 text-white shadow-md' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>
|
||||||
|
Courts
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Title (Responsive truncation) */}
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 text-center hidden sm:block pointer-events-none">
|
||||||
|
<div className="font-black uppercase text-xs tracking-[0.2em] text-zinc-900 dark:text-white truncate max-w-37.5 md:max-w-62.5">
|
||||||
{title || 'Dashboard'}
|
{title || 'Dashboard'}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<div className="text-[8px] sm:text-[10px] font-black text-zinc-400 dark:text-zinc-500 uppercase tracking-widest leading-none">
|
<div className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest mt-0.5">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 sm:gap-4 items-center shrink-0">
|
{/* Right: Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<>
|
<button onClick={onLogout} className="p-2 text-zinc-400 hover:text-red-500 transition rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||||
<button onClick={onLogout} title="Sign Out" className="text-zinc-400 hover:text-red-500 transition active:scale-90 shrink-0">
|
<LogOut size={20} />
|
||||||
<LogOut size={18} className="sm:size-5.5" />
|
</button>
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<Link to="/login" className="text-orange-600 font-black flex items-center gap-1.5 text-[9px] sm:text-[10px] uppercase tracking-widest hover:text-orange-500 transition group p-1.5 sm:p-2 rounded-xl hover:bg-orange-50 dark:hover:bg-orange-950/20">
|
<Link to="/login" className="flex items-center gap-2 px-4 py-2 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 rounded-lg text-xs font-black uppercase tracking-wider hover:bg-orange-100 transition">
|
||||||
<Lock size={12} className="sm:size-3.5 group-hover:-translate-y-0.5 transition-transform" /> <span className="hidden xs:inline">Login</span>
|
<Lock size={14} /> Login
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// frontend/src/components/Tournament/ScoreModal.tsx
|
// frontend/src/components/Tournament/ScoreModal.tsx
|
||||||
|
|
||||||
import { type SetData } from '../../types';
|
import { type SetData } from '../../types';
|
||||||
import { Clock, Eraser, MapPin, Trophy } from 'lucide-react';
|
import { Clock, Eraser, Trophy } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Modal from '../UI/Modal';
|
import Modal from '../UI/Modal';
|
||||||
import WhistleIcon from "../../assets/whistle.svg?react"
|
import WhistleIcon from "../../assets/whistle.svg?react"
|
||||||
|
import CourtIcon from '../../assets/court.svg?react';
|
||||||
|
|
||||||
interface MatchData {
|
interface MatchData {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -64,7 +65,7 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-px bg-zinc-300 dark:bg-zinc-800" />
|
<div className="h-4 w-px bg-zinc-300 dark:bg-zinc-800" />
|
||||||
<div className="flex items-center gap-2 text-sm font-mono text-zinc-900 dark:text-white">
|
<div className="flex items-center gap-2 text-sm font-mono text-zinc-900 dark:text-white">
|
||||||
<MapPin className="text-orange-500" size={16} />
|
<CourtIcon className="text-orange-500" width={16} height={16} />
|
||||||
<span>{match.court || "TBD"}</span>
|
<span>{match.court || "TBD"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// frontend/src/pages/Courts.tsx
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Loader2, Trash, Plus } from 'lucide-react';
|
||||||
|
import { useOutletContext, Link } from 'react-router-dom';
|
||||||
|
import api from '../services/api';
|
||||||
|
import { printName } from '../utils/helpers';
|
||||||
|
import WhistleIcon from '../assets/whistle.svg?react';
|
||||||
|
|
||||||
|
import CourtIcon from '../assets/court.svg?react';
|
||||||
|
|
||||||
|
interface CourtMatch {
|
||||||
|
id: string;
|
||||||
|
tournament_id: string;
|
||||||
|
tournament_name: string;
|
||||||
|
time: string;
|
||||||
|
status: string;
|
||||||
|
match_number: number;
|
||||||
|
p1: string;
|
||||||
|
p2: string;
|
||||||
|
p1_sets: number;
|
||||||
|
p2_sets: number;
|
||||||
|
ref_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourtSchedule {
|
||||||
|
court: string;
|
||||||
|
matches: CourtMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalCourt {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutletContext {
|
||||||
|
setNavTitle: (t: string) => void;
|
||||||
|
setNavSubtitle: (s: string) => void;
|
||||||
|
role: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (id: number) => void, role: string | null }) => {
|
||||||
|
const [data, setData] = useState<CourtSchedule | null>(null);
|
||||||
|
const [currentTime, setCurrentTime] = useState<string>('');
|
||||||
|
|
||||||
|
// Auto-scroll refs
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [hasScrolled, setHasScrolled] = useState(false);
|
||||||
|
|
||||||
|
// Keep the current time perfectly updated
|
||||||
|
useEffect(() => {
|
||||||
|
const updateTime = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const hours = now.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||||
|
setCurrentTime(`${hours}:${minutes}`);
|
||||||
|
};
|
||||||
|
updateTime();
|
||||||
|
const interval = setInterval(updateTime, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSchedule = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<CourtSchedule>(`/courts/${courtId}/schedule`);
|
||||||
|
setData(res);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void fetchSchedule();
|
||||||
|
}, [courtId]);
|
||||||
|
|
||||||
|
// AUTO-SCROLL LOGIC
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && currentTime && !hasScrolled && scrollContainerRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const line = scrollContainerRef.current?.querySelector('.now-line');
|
||||||
|
if (line) {
|
||||||
|
line.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
setHasScrolled(true);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [data, currentTime, hasScrolled]);
|
||||||
|
|
||||||
|
if (!data) return <div className="w-80 shrink-0 bg-zinc-50 dark:bg-zinc-900/50 rounded-2xl flex items-center justify-center border border-zinc-200 dark:border-zinc-800"><Loader2 className="animate-spin text-orange-500" /></div>;
|
||||||
|
|
||||||
|
// --- ACTIVE INDEX LOGIC ---
|
||||||
|
let activeIndex = -1;
|
||||||
|
if (currentTime) {
|
||||||
|
// Find the index of the most recently started match
|
||||||
|
for (let i = 0; i < data.matches.length; i++) {
|
||||||
|
if (data.matches[i].time <= currentTime) {
|
||||||
|
activeIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-72 md:w-80 shrink-0 flex flex-col h-full bg-zinc-100/50 dark:bg-zinc-900/20 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||||
|
<div className="p-3 md:p-4 bg-zinc-100 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-3 shrink-0">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<CourtIcon className="text-orange-500 shrink-0" width={20} height={20} />
|
||||||
|
<h3 className="font-black text-base md:text-lg text-zinc-900 dark:text-white uppercase tracking-wider truncate">{data.court}</h3>
|
||||||
|
</div>
|
||||||
|
{role === 'admin' && (
|
||||||
|
<button onClick={() => onDelete(courtId)} className="text-zinc-400 hover:text-red-500 transition p-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 active:scale-90" title="Delete Court">
|
||||||
|
<Trash size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3 space-y-2.5 pb-32">
|
||||||
|
{data.matches.length === 0 && (
|
||||||
|
<div className="text-center text-sm font-bold uppercase text-zinc-400 dark:text-zinc-600 mt-10">No matches</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.matches.map((m, index) => {
|
||||||
|
const isFinished = m.status === 'Finished';
|
||||||
|
const isPastSlot = index < activeIndex; // Strictly before the active match
|
||||||
|
const isLive = index === activeIndex && !isFinished; // Currently active and incomplete
|
||||||
|
|
||||||
|
// The line is drawn right above the active index (or index 0 if the day hasn't started)
|
||||||
|
const showNowLine = (activeIndex !== -1 && index === activeIndex) || (activeIndex === -1 && index === 0);
|
||||||
|
|
||||||
|
// Styling configurations based on match state
|
||||||
|
let borderStyle = 'border-zinc-200 dark:border-zinc-800 hover:-translate-y-1 hover:shadow-md';
|
||||||
|
let textOpacity = 'text-zinc-900 dark:text-white';
|
||||||
|
let pOpacity = 'text-zinc-800 dark:text-zinc-200';
|
||||||
|
|
||||||
|
if (isFinished) {
|
||||||
|
borderStyle = 'border-orange-500/30 opacity-60 grayscale hover:opacity-100 hover:grayscale-0';
|
||||||
|
textOpacity = 'text-zinc-500';
|
||||||
|
} else if (isPastSlot) {
|
||||||
|
borderStyle = 'border-zinc-300 dark:border-zinc-700 opacity-50 grayscale hover:opacity-100 hover:grayscale-0';
|
||||||
|
textOpacity = 'text-zinc-500';
|
||||||
|
pOpacity = 'text-zinc-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLive) {
|
||||||
|
borderStyle = 'border-orange-500 ring-1 ring-orange-500/20 shadow-sm hover:-translate-y-1 hover:shadow-md';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={m.id}>
|
||||||
|
{/* --- THE --NOW-- LINE --- */}
|
||||||
|
{showNowLine && (
|
||||||
|
<div className="now-line relative flex items-center py-3 animate-in fade-in">
|
||||||
|
<div className="flex-1 border-t-2 border-red-500 rounded-full"></div>
|
||||||
|
<div className="mx-2 text-[10px] font-black text-red-600 uppercase tracking-widest bg-red-100 dark:bg-red-900/30 px-3 py-1 rounded-full border border-red-200 dark:border-red-900/50 shadow-sm">Now</div>
|
||||||
|
<div className="flex-1 border-t-2 border-red-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/tournaments/${m.tournament_id}`}
|
||||||
|
className={`match-card block bg-white dark:bg-zinc-950 p-2.5 rounded-lg border transition-all ${borderStyle}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`text-base font-black font-mono leading-none ${textOpacity}`}>{m.time}</div>
|
||||||
|
{isLive && (
|
||||||
|
<div className="flex items-center gap-1 bg-orange-100 dark:bg-orange-900/40 text-orange-600 dark:text-orange-500 px-1.5 py-0.5 rounded-full border border-orange-200 dark:border-orange-800/50">
|
||||||
|
<span className="w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse"></span>
|
||||||
|
<span className="text-[7px] font-black uppercase tracking-widest">Live</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[7px] font-black uppercase text-zinc-500 bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded truncate max-w-22.5">
|
||||||
|
{m.tournament_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5 mb-1.5">
|
||||||
|
<div className="flex justify-between items-center text-xs leading-tight">
|
||||||
|
<span className={`font-bold truncate pr-2 ${isFinished && m.p1_sets > m.p2_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p1)}</span>
|
||||||
|
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p1_sets}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-xs leading-tight">
|
||||||
|
<span className={`font-bold truncate pr-2 ${isFinished && m.p2_sets > m.p1_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p2)}</span>
|
||||||
|
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p2_sets}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.ref_name && !isFinished && (
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-zinc-100 dark:border-zinc-800 flex items-center gap-1 text-[8px] font-bold text-zinc-500 dark:text-zinc-400">
|
||||||
|
<WhistleIcon className="text-orange-500" width={10} height={10} />
|
||||||
|
<span className="uppercase tracking-widest truncate">{m.ref_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Courts() {
|
||||||
|
const { setNavTitle, setNavSubtitle, role } = useOutletContext<OutletContext>();
|
||||||
|
const [courts, setCourts] = useState<GlobalCourt[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newCourtName, setNewCourtName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNavTitle('Courts');
|
||||||
|
setNavSubtitle('');
|
||||||
|
|
||||||
|
const fetchCourts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<GlobalCourt[]>('/courts');
|
||||||
|
setCourts(res);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchCourts();
|
||||||
|
}, [setNavTitle, setNavSubtitle]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!window.confirm("Are you sure you want to delete this court? It will remove it from all tournaments and auto-reschedule them.")) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/courts/${id}`);
|
||||||
|
setCourts(courts.filter(c => c.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete court", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCourt = async () => {
|
||||||
|
if (!newCourtName.trim()) return;
|
||||||
|
try {
|
||||||
|
const added = await api.post<GlobalCourt>('/courts', { name: newCourtName.trim() });
|
||||||
|
setCourts([...courts, added]);
|
||||||
|
setNewCourtName('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create new court", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full overflow-hidden flex flex-col bg-white dark:bg-zinc-950 pt-8 pb-8">
|
||||||
|
<div className="flex-1 overflow-x-auto overflow-y-hidden px-6 md:px-12">
|
||||||
|
<div className="flex h-full gap-6 pb-4 min-w-max">
|
||||||
|
{courts.map(c => (
|
||||||
|
<CourtColumn key={c.id} courtId={c.id} onDelete={handleDelete} role={role} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{courts.length === 0 && role !== 'admin' && (
|
||||||
|
<div className="text-center text-zinc-500 font-bold uppercase mt-20 w-full">
|
||||||
|
No physical courts registered yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- ADMIN PANEL: Add New Court Inline --- */}
|
||||||
|
{role === 'admin' && (
|
||||||
|
<div className="w-72 md:w-80 shrink-0 flex flex-col h-full bg-zinc-100/30 dark:bg-zinc-900/10 rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-800 p-6 items-center justify-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-400">
|
||||||
|
<CourtIcon width={32} height={32} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-zinc-500 uppercase text-center">Add New Court</div>
|
||||||
|
<div className="flex flex-col w-full gap-3 mt-2">
|
||||||
|
<input
|
||||||
|
value={newCourtName}
|
||||||
|
onChange={(e) => setNewCourtName(e.target.value)}
|
||||||
|
placeholder="Court name..."
|
||||||
|
className="w-full bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded-xl px-4 py-3 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white shadow-sm"
|
||||||
|
/>
|
||||||
|
<button onClick={handleAddCourt} className="w-full bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 py-3 rounded-xl font-bold uppercase text-xs tracking-wider transition flex items-center justify-center gap-2 active:scale-95">
|
||||||
|
<Plus size={16} /> Add Court
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,10 +47,12 @@ export default function Dashboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNavTitle('Dashboard');
|
setNavTitle('Dashboard');
|
||||||
setNavSubtitle('');
|
setNavSubtitle('');
|
||||||
void loadDashboard();
|
|
||||||
|
|
||||||
localStorage.removeItem('volley_view');
|
localStorage.removeItem('volley_view');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await loadDashboard();
|
||||||
|
})();
|
||||||
|
|
||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function Login() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -149,7 +149,10 @@ export default function Tournament() {
|
|||||||
}, [id, setNavTitle, setNavSubtitle]);
|
}, [id, setNavTitle, setNavSubtitle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchData();
|
(async () => {
|
||||||
|
await fetchData();
|
||||||
|
})();
|
||||||
|
|
||||||
if (wsRef.current) return;
|
if (wsRef.current) return;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user