Commit 8b678c5b authored by Mahmoud Aglan's avatar Mahmoud Aglan

tournament update

parent e87eca4e
...@@ -6,7 +6,7 @@ $supabase = ApiProxy::healthCheck( ...@@ -6,7 +6,7 @@ $supabase = ApiProxy::healthCheck(
); );
$stockfish = ApiProxy::healthCheck(STOCKFISH_API_URL . '/health'); $stockfish = ApiProxy::healthCheck(STOCKFISH_API_URL . '/health');
$swiss = ApiProxy::healthCheck(SWISS_API_URL . '/health'); $swiss = ApiProxy::healthCheck(SWISS_API_BASE . '/health');
Response::json([ Response::json([
'supabase' => $supabase, 'supabase' => $supabase,
......
...@@ -13,6 +13,9 @@ define('STOCKFISH_API_URL', getenv('STOCKFISH_API_URL') ?: 'https://stockfishapi ...@@ -13,6 +13,9 @@ define('STOCKFISH_API_URL', getenv('STOCKFISH_API_URL') ?: 'https://stockfishapi
define('STOCKFISH_API_KEY', getenv('STOCKFISH_API_KEY') ?: 'sk-alarc-stockfish-mgmt-2024'); define('STOCKFISH_API_KEY', getenv('STOCKFISH_API_KEY') ?: 'sk-alarc-stockfish-mgmt-2024');
define('SWISS_API_URL', getenv('SWISS_API_URL') ?: 'https://swissapi.caprover.al-arcade.com/api/v1'); define('SWISS_API_URL', getenv('SWISS_API_URL') ?: 'https://swissapi.caprover.al-arcade.com/api/v1');
define('SWISS_API_BASE', getenv('SWISS_API_BASE') ?: 'https://swissapi.caprover.al-arcade.com');
define('SWISS_API_EMAIL', getenv('SWISS_API_EMAIL') ?: 'management@al-arcade.com');
define('SWISS_API_PASSWORD', getenv('SWISS_API_PASSWORD') ?: 'Alarcade123#');
define('ADMIN_USERNAME', getenv('ADMIN_USERNAME') ?: 'admin'); define('ADMIN_USERNAME', getenv('ADMIN_USERNAME') ?: 'admin');
define('ADMIN_PASSWORD_HASH', '$2y$12$6HZ3kC4ogVWhgm1ZaU7.A.oM3xJC6aYHL9Iw.5eZ84tVrEjDQE9zO'); define('ADMIN_PASSWORD_HASH', '$2y$12$6HZ3kC4ogVWhgm1ZaU7.A.oM3xJC6aYHL9Iw.5eZ84tVrEjDQE9zO');
......
...@@ -65,7 +65,7 @@ return [ ...@@ -65,7 +65,7 @@ return [
'chess-bots/test-move' => ['module' => 'chess-bots', 'action' => 'testMove'], 'chess-bots/test-move' => ['module' => 'chess-bots', 'action' => 'testMove'],
'chess-bots/pool' => ['module' => 'chess-bots', 'action' => 'pool'], 'chess-bots/pool' => ['module' => 'chess-bots', 'action' => 'pool'],
// Tournaments // Tournaments - Core
'tournaments' => ['module' => 'tournaments', 'action' => 'list'], 'tournaments' => ['module' => 'tournaments', 'action' => 'list'],
'tournaments/create' => ['module' => 'tournaments', 'action' => 'create'], 'tournaments/create' => ['module' => 'tournaments', 'action' => 'create'],
'tournaments/store' => ['module' => 'tournaments', 'action' => 'store'], 'tournaments/store' => ['module' => 'tournaments', 'action' => 'store'],
...@@ -75,9 +75,49 @@ return [ ...@@ -75,9 +75,49 @@ return [
'tournaments/{id}/start' => ['module' => 'tournaments', 'action' => 'start'], 'tournaments/{id}/start' => ['module' => 'tournaments', 'action' => 'start'],
'tournaments/{id}/complete' => ['module' => 'tournaments', 'action' => 'complete'], 'tournaments/{id}/complete' => ['module' => 'tournaments', 'action' => 'complete'],
'tournaments/{id}/cancel' => ['module' => 'tournaments', 'action' => 'cancel'], 'tournaments/{id}/cancel' => ['module' => 'tournaments', 'action' => 'cancel'],
// Tournaments - Swiss Rounds & Results
'tournaments/{id}/rounds/generate' => ['module' => 'tournaments', 'action' => 'generateRound'], 'tournaments/{id}/rounds/generate' => ['module' => 'tournaments', 'action' => 'generateRound'],
'tournaments/{id}/rounds/{roundId}/results' => ['module' => 'tournaments', 'action' => 'submitResults'], 'tournaments/{id}/rounds/{roundId}/results' => ['module' => 'tournaments', 'action' => 'submitResults'],
'tournaments/{id}/rounds/{roundId}/pairings' => ['module' => 'tournaments', 'action' => 'pairings'],
'tournaments/{id}/rounds/{roundId}/pairings/manual' => ['module' => 'tournaments', 'action' => 'manualPairing'],
'tournaments/{id}/rounds/{roundId}/unpair' => ['module' => 'tournaments', 'action' => 'unpairRound'],
'tournaments/{id}/pairings/{pairingId}/delete' => ['module' => 'tournaments', 'action' => 'deletePairing'],
'tournaments/{id}/standings' => ['module' => 'tournaments', 'action' => 'standings'], 'tournaments/{id}/standings' => ['module' => 'tournaments', 'action' => 'standings'],
'tournaments/{id}/standings/recalculate' => ['module' => 'tournaments', 'action' => 'recalculateStandings'],
// Tournaments - Categories
'tournaments/{id}/categories' => ['module' => 'tournaments', 'action' => 'categories'],
'tournaments/{id}/categories/store' => ['module' => 'tournaments', 'action' => 'storeCategory'],
'tournaments/{id}/categories/{catId}/delete' => ['module' => 'tournaments', 'action' => 'deleteCategory'],
// Tournaments - Players
'tournaments/{id}/players/import' => ['module' => 'tournaments', 'action' => 'importPlayers'],
'tournaments/{id}/players/{playerId}/withdraw' => ['module' => 'tournaments', 'action' => 'withdrawPlayer'],
// Tournaments - Tiebreaks & Export
'tournaments/{id}/tiebreaks/update' => ['module' => 'tournaments', 'action' => 'updateTiebreaks'],
'tournaments/{id}/export/{format}' => ['module' => 'tournaments', 'action' => 'export'],
// Tournaments - Multi-Phase
'tournaments/{id}/phases' => ['module' => 'tournaments', 'action' => 'phases'],
'tournaments/{id}/phases/{phaseId}/start' => ['module' => 'tournaments', 'action' => 'startPhase'],
'tournaments/{id}/phases/{phaseId}/complete' => ['module' => 'tournaments', 'action' => 'completePhase'],
'tournaments/{id}/phases/{phaseId}/advance' => ['module' => 'tournaments', 'action' => 'advancePlayers'],
// Tournaments - Bracket
'tournaments/{id}/bracket' => ['module' => 'tournaments', 'action' => 'bracket'],
'tournaments/{id}/bracket/matches/{matchId}/result' => ['module' => 'tournaments', 'action' => 'bracketResult'],
// Tournaments - Arena
'tournaments/{id}/arena/pair' => ['module' => 'tournaments', 'action' => 'arenaPair'],
'tournaments/{id}/arena/result' => ['module' => 'tournaments', 'action' => 'arenaResult'],
// Tournaments - JSON API (AJAX)
'api/tournaments/{id}/standings' => ['module' => 'tournaments', 'action' => 'apiStandings'],
'api/tournaments/{id}/pairings/{roundId}' => ['module' => 'tournaments', 'action' => 'apiPairings'],
'api/tournaments/{id}/bracket' => ['module' => 'tournaments', 'action' => 'apiBracket'],
'api/tournaments/{id}/arena' => ['module' => 'tournaments', 'action' => 'apiArena'],
// Organizations // Organizations
'organizations' => ['module' => 'organizations', 'action' => 'list'], 'organizations' => ['module' => 'organizations', 'action' => 'list'],
......
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
class ApiProxy class ApiProxy
{ {
private static ?string $swissToken = null;
private static int $swissTokenExpiry = 0;
public static function stockfish(string $method, string $path, ?array $body = null): array public static function stockfish(string $method, string $path, ?array $body = null): array
{ {
return self::request($method, STOCKFISH_API_URL . $path, $body, [ return self::request($method, STOCKFISH_API_URL . $path, $body, [
...@@ -13,12 +16,33 @@ class ApiProxy ...@@ -13,12 +16,33 @@ class ApiProxy
public static function swiss(string $method, string $path, ?array $body = null, ?string $token = null): array public static function swiss(string $method, string $path, ?array $body = null, ?string $token = null): array
{ {
$headers = ['Content-Type: application/json']; $headers = ['Content-Type: application/json'];
if ($token) { $authToken = $token ?? self::getSwissToken();
$headers[] = 'Authorization: Bearer ' . $token; if ($authToken) {
$headers[] = 'Authorization: Bearer ' . $authToken;
} }
return self::request($method, SWISS_API_URL . $path, $body, $headers); return self::request($method, SWISS_API_URL . $path, $body, $headers);
} }
private static function getSwissToken(): ?string
{
if (self::$swissToken && time() < self::$swissTokenExpiry) {
return self::$swissToken;
}
$response = self::request('POST', SWISS_API_URL . '/auth/login', [
'email' => SWISS_API_EMAIL,
'password' => SWISS_API_PASSWORD,
], ['Content-Type: application/json']);
if ($response['status'] === 200 && !empty($response['body']['accessToken'])) {
self::$swissToken = $response['body']['accessToken'];
self::$swissTokenExpiry = ($response['body']['expiresAt'] ?? time() + 3500) - 60;
return self::$swissToken;
}
return null;
}
public static function request(string $method, string $url, ?array $body = null, array $headers = []): array public static function request(string $method, string $url, ?array $body = null, array $headers = []): array
{ {
$ch = curl_init(); $ch = curl_init();
......
-- Migration: Tournament Phases, Brackets, and Multi-Phase Support
-- Run against Supabase PostgreSQL
-- Modify el3ab_tournaments to support multi-phase
ALTER TABLE el3ab_tournaments
ADD COLUMN IF NOT EXISTS tournament_mode TEXT DEFAULT 'single' CHECK (tournament_mode IN ('single','multi_phase')),
ADD COLUMN IF NOT EXISTS phase_config JSONB DEFAULT '[]',
ADD COLUMN IF NOT EXISTS current_phase INT DEFAULT 1,
ADD COLUMN IF NOT EXISTS total_phases INT DEFAULT 1,
ADD COLUMN IF NOT EXISTS tiebreak_rules JSONB DEFAULT '["buchholz_cut_1","buchholz","sonneborn_berger"]',
ADD COLUMN IF NOT EXISTS acceleration_method TEXT,
ADD COLUMN IF NOT EXISTS acceleration_rounds INT DEFAULT 0;
-- Tournament Phases
CREATE TABLE IF NOT EXISTS tournament_phases (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
tournament_id UUID NOT NULL REFERENCES el3ab_tournaments(id) ON DELETE CASCADE,
phase_number INT NOT NULL,
name TEXT NOT NULL,
name_ar TEXT,
type TEXT NOT NULL CHECK (type IN ('swiss','round_robin','single_elimination','double_elimination','arena','group_stage')),
config JSONB DEFAULT '{}',
swiss_api_tournament_id UUID,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending','in_progress','completed')),
advancement_rule JSONB DEFAULT '{}',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(tournament_id, phase_number)
);
-- Tournament Brackets
CREATE TABLE IF NOT EXISTS tournament_brackets (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
tournament_id UUID NOT NULL REFERENCES el3ab_tournaments(id) ON DELETE CASCADE,
phase_id UUID NOT NULL REFERENCES tournament_phases(id) ON DELETE CASCADE,
bracket_type TEXT NOT NULL CHECK (bracket_type IN ('winners','losers','consolation','group')),
group_name TEXT,
total_rounds INT NOT NULL,
current_round INT DEFAULT 0,
config JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Bracket Matches
CREATE TABLE IF NOT EXISTS bracket_matches (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
bracket_id UUID NOT NULL REFERENCES tournament_brackets(id) ON DELETE CASCADE,
tournament_id UUID NOT NULL REFERENCES el3ab_tournaments(id) ON DELETE CASCADE,
phase_id UUID NOT NULL REFERENCES tournament_phases(id) ON DELETE CASCADE,
round_number INT NOT NULL,
match_number INT NOT NULL,
player_a_id TEXT,
player_a_name TEXT,
player_a_seed INT,
player_b_id TEXT,
player_b_name TEXT,
player_b_seed INT,
result TEXT CHECK (result IN ('player_a_wins','player_b_wins','draw','not_played','forfeit_a','forfeit_b','bye')),
score_a TEXT,
score_b TEXT,
winner_id TEXT,
next_match_id UUID REFERENCES bracket_matches(id),
next_match_slot TEXT CHECK (next_match_slot IN ('player_a','player_b')),
loser_next_match_id UUID REFERENCES bracket_matches(id),
loser_next_match_slot TEXT CHECK (loser_next_match_slot IN ('player_a','player_b')),
source_match_a_id UUID REFERENCES bracket_matches(id),
source_match_b_id UUID REFERENCES bracket_matches(id),
scheduled_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending','ready','in_progress','completed','bye')),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_bracket_matches_bracket ON bracket_matches(bracket_id);
CREATE INDEX IF NOT EXISTS idx_bracket_matches_tournament ON bracket_matches(tournament_id);
CREATE INDEX IF NOT EXISTS idx_bracket_matches_phase ON bracket_matches(phase_id);
CREATE INDEX IF NOT EXISTS idx_tournament_phases_tournament ON tournament_phases(tournament_id);
CREATE INDEX IF NOT EXISTS idx_tournament_brackets_tournament ON tournament_brackets(tournament_id);
CREATE INDEX IF NOT EXISTS idx_tournament_brackets_phase ON tournament_brackets(phase_id);
-- Enable RLS
ALTER TABLE tournament_phases ENABLE ROW LEVEL SECURITY;
ALTER TABLE tournament_brackets ENABLE ROW LEVEL SECURITY;
ALTER TABLE bracket_matches ENABLE ROW LEVEL SECURITY;
-- Allow service_role full access
CREATE POLICY "service_role_all_tournament_phases" ON tournament_phases FOR ALL TO service_role USING (true) WITH CHECK (true);
CREATE POLICY "service_role_all_tournament_brackets" ON tournament_brackets FOR ALL TO service_role USING (true) WITH CHECK (true);
CREATE POLICY "service_role_all_bracket_matches" ON bracket_matches FOR ALL TO service_role USING (true) WITH CHECK (true);
...@@ -76,7 +76,7 @@ class DashboardController ...@@ -76,7 +76,7 @@ class DashboardController
); );
$stockfishHealth = ApiProxy::healthCheck(STOCKFISH_API_URL . '/health'); $stockfishHealth = ApiProxy::healthCheck(STOCKFISH_API_URL . '/health');
$swissHealth = ApiProxy::healthCheck(SWISS_API_URL . '/health'); $swissHealth = ApiProxy::healthCheck(SWISS_API_BASE . '/health');
return compact( return compact(
'totalPlayers', 'onlinePlayers', 'totalMatches', 'activeMatches', 'totalPlayers', 'onlinePlayers', 'totalMatches', 'activeMatches',
......
/**
* Bracket SVG connector rendering
*/
(function() {
'use strict';
function drawBracketConnectors() {
const container = document.getElementById('bracketContainer');
if (!container) return;
const svgEl = document.getElementById('bracketConnectors');
if (!svgEl) return;
svgEl.innerHTML = '';
const containerRect = container.getBoundingClientRect();
svgEl.setAttribute('width', containerRect.width);
svgEl.setAttribute('height', containerRect.height);
const sections = container.querySelectorAll('.bracket-section');
sections.forEach(section => {
const rounds = section.querySelectorAll('.bracket-round');
for (let i = 0; i < rounds.length - 1; i++) {
const currentMatches = rounds[i].querySelectorAll('.bracket-match');
const nextMatches = rounds[i + 1].querySelectorAll('.bracket-match');
currentMatches.forEach((match, idx) => {
const nextIdx = Math.floor(idx / 2);
const nextMatch = nextMatches[nextIdx];
if (!nextMatch) return;
const fromRect = match.getBoundingClientRect();
const toRect = nextMatch.getBoundingClientRect();
const x1 = fromRect.left - containerRect.left + fromRect.width;
const y1 = fromRect.top - containerRect.top + fromRect.height / 2;
const x2 = toRect.left - containerRect.left;
const y2 = toRect.top - containerRect.top + toRect.height / 2;
const midX = (x1 + x2) / 2;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', `M${x1},${y1} C${midX},${y1} ${midX},${y2} ${x2},${y2}`);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', 'var(--border-color, #333)');
path.setAttribute('stroke-width', '1.5');
path.setAttribute('opacity', '0.5');
svgEl.appendChild(path);
});
}
});
}
// Bracket result modal
window.openBracketResultModal = function(matchId, playerAName, playerBName) {
const modal = document.getElementById('bracketResultModal');
const form = document.getElementById('bracketResultForm');
const tournamentId = window.location.pathname.split('/')[2];
form.action = '/tournaments/' + tournamentId + '/bracket/matches/' + matchId + '/result';
document.getElementById('bracketPlayerAName').textContent = playerAName;
document.getElementById('bracketPlayerBName').textContent = playerBName;
// Reset radio
form.querySelectorAll('input[name="result"]').forEach(r => r.checked = false);
form.querySelectorAll('input[name="score_a"], input[name="score_b"]').forEach(r => r.value = '');
modal.style.display = 'flex';
};
window.closeBracketResultModal = function() {
document.getElementById('bracketResultModal').style.display = 'none';
};
// Draw on load and resize
document.addEventListener('DOMContentLoaded', () => {
setTimeout(drawBracketConnectors, 100);
});
window.addEventListener('resize', () => {
clearTimeout(window._bracketResizeTimer);
window._bracketResizeTimer = setTimeout(drawBracketConnectors, 200);
});
})();
/**
* Realtime updates for tournament views
* Uses polling as primary mechanism with Supabase Realtime as enhancement
*/
(function() {
'use strict';
const POLL_INTERVAL = 30000; // 30 seconds
let pollTimer = null;
let tournamentId = null;
function init() {
const path = window.location.pathname;
const match = path.match(/\/tournaments\/([^/]+)/);
if (!match) return;
tournamentId = match[1];
const tab = new URLSearchParams(window.location.search).get('tab');
if (tab === 'standings') {
startPolling(refreshStandings);
} else if (tab === 'bracket') {
startPolling(refreshBracket);
} else if (tab === 'arena') {
// Arena has its own faster polling in _arena_board.php
return;
}
// Try Supabase Realtime if available
initSupabaseRealtime(tab);
}
function startPolling(callback) {
pollTimer = setInterval(callback, POLL_INTERVAL);
}
function refreshStandings() {
if (!tournamentId) return;
fetch('/api/tournaments/' + tournamentId + '/standings')
.then(r => r.json())
.then(data => {
if (data.standings) {
updateStandingsTable(data.standings);
}
})
.catch(() => {});
}
function refreshBracket() {
if (!tournamentId) return;
fetch('/api/tournaments/' + tournamentId + '/bracket')
.then(r => r.json())
.then(data => {
if (data.brackets) {
// Simple: reload page on bracket changes
const hasChanges = detectBracketChanges(data.brackets);
if (hasChanges) {
window.location.reload();
}
}
})
.catch(() => {});
}
function updateStandingsTable(standings) {
const tbody = document.querySelector('.data-table tbody');
if (!tbody || !standings.length) return;
const rows = tbody.querySelectorAll('tr');
standings.forEach((s, i) => {
if (!rows[i]) return;
const cells = rows[i].querySelectorAll('td');
if (cells.length >= 8) {
cells[2].textContent = s.points ?? 0;
cells[3].textContent = s.games_played ?? 0;
cells[4].textContent = s.wins ?? 0;
cells[5].textContent = s.draws ?? 0;
cells[6].textContent = s.losses ?? 0;
cells[7].textContent = s.tiebreak ?? 0;
}
});
}
function detectBracketChanges(brackets) {
const currentMatches = document.querySelectorAll('.bracket-match.completed');
let totalCompleted = 0;
brackets.forEach(b => {
Object.values(b.rounds || {}).forEach(round => {
round.forEach(m => {
if (m.status === 'completed') totalCompleted++;
});
});
});
return totalCompleted !== currentMatches.length;
}
function initSupabaseRealtime(tab) {
// Only if Supabase JS client is loaded via CDN
if (typeof window.supabase === 'undefined') return;
try {
const client = window.supabase.createClient(
window.SUPABASE_URL || '',
window.SUPABASE_ANON_KEY || ''
);
if (tab === 'standings' || tab === 'rounds') {
client.channel('tournament_' + tournamentId)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'el3ab_tournament_rounds',
filter: 'tournament_id=eq.' + tournamentId,
}, () => {
if (tab === 'standings') refreshStandings();
else window.location.reload();
})
.subscribe();
}
if (tab === 'bracket') {
client.channel('bracket_' + tournamentId)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'bracket_matches',
filter: 'tournament_id=eq.' + tournamentId,
}, () => {
window.location.reload();
})
.subscribe();
}
} catch (e) {
// Silently fall back to polling
}
}
// Cleanup
window.addEventListener('beforeunload', () => {
if (pollTimer) clearInterval(pollTimer);
});
document.addEventListener('DOMContentLoaded', init);
})();
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<?php
class ArenaEngine
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function pairNextAvailable(string $phaseId): array
{
if (!$this->isArenaActive($phaseId)) {
return ['error' => 'Arena has ended'];
}
$bracket = $this->db->selectOne('tournament_brackets', [
'phase_id' => "eq.{$phaseId}",
]);
if (!$bracket) {
return ['error' => 'No arena bracket found'];
}
$phase = $this->db->selectOne('tournament_phases', ['id' => "eq.{$phaseId}"]);
$tournamentId = $phase['tournament_id'];
// Get all registered players
$registrations = $this->db->select('tournament_registrations', [
'tournament_id' => "eq.{$tournamentId}",
]);
// Get currently active matches
$activeMatches = $this->db->select('bracket_matches', [
'phase_id' => "eq.{$phaseId}",
'status' => 'eq.in_progress',
]);
$busyPlayers = [];
foreach ($activeMatches as $m) {
$busyPlayers[$m['player_a_id']] = true;
$busyPlayers[$m['player_b_id']] = true;
}
// Find idle players
$idlePlayers = [];
foreach ($registrations as $reg) {
if (!isset($busyPlayers[$reg['player_id']])) {
$idlePlayers[] = [
'id' => $reg['player_id'],
'name' => $reg['player_name'] ?? $reg['player_id'],
'score' => $this->getArenaScore($phaseId, $reg['player_id']),
];
}
}
if (count($idlePlayers) < 2) {
return ['waiting' => true, 'idle_count' => count($idlePlayers)];
}
// Sort by score for similar-strength pairing
usort($idlePlayers, fn($a, $b) => $b['score'] <=> $a['score']);
// Pair adjacent players (similar scores)
$paired = [];
$roundNumber = $bracket['current_round'] + 1;
$matchNumber = $this->db->count('bracket_matches', ['phase_id' => "eq.{$phaseId}"]) + 1;
for ($i = 0; $i < count($idlePlayers) - 1; $i += 2) {
$playerA = $idlePlayers[$i];
$playerB = $idlePlayers[$i + 1];
$match = $this->db->insert('bracket_matches', [
'bracket_id' => $bracket['id'],
'tournament_id' => $tournamentId,
'phase_id' => $phaseId,
'round_number' => $roundNumber,
'match_number' => $matchNumber,
'player_a_id' => $playerA['id'],
'player_a_name' => $playerA['name'],
'player_b_id' => $playerB['id'],
'player_b_name' => $playerB['name'],
'status' => 'in_progress',
'started_at' => date('c'),
]);
$paired[] = $match;
$matchNumber++;
}
$this->db->update('tournament_brackets', ['id' => "eq.{$bracket['id']}"], [
'current_round' => $roundNumber,
'updated_at' => date('c'),
]);
return ['paired' => count($paired), 'matches' => $paired];
}
public function submitArenaResult(string $matchId, string $result): array
{
$match = $this->db->selectOne('bracket_matches', ['id' => "eq.{$matchId}"]);
if (!$match) {
return ['error' => 'Match not found'];
}
if ($match['status'] !== 'in_progress') {
return ['error' => 'Match is not in progress'];
}
$winnerId = match ($result) {
'player_a_wins' => $match['player_a_id'],
'player_b_wins' => $match['player_b_id'],
default => null,
};
$this->db->update('bracket_matches', ['id' => "eq.{$matchId}"], [
'result' => $result,
'winner_id' => $winnerId,
'status' => 'completed',
'completed_at' => date('c'),
]);
// Auto-pair freed players if arena is still active
if ($this->isArenaActive($match['phase_id'])) {
$this->pairNextAvailable($match['phase_id']);
}
return ['success' => true, 'winner_id' => $winnerId];
}
public function getArenaStandings(string $phaseId): array
{
$phase = $this->db->selectOne('tournament_phases', ['id' => "eq.{$phaseId}"]);
if (!$phase) return [];
$registrations = $this->db->select('tournament_registrations', [
'tournament_id' => "eq.{$phase['tournament_id']}",
]);
$standings = [];
foreach ($registrations as $reg) {
$playerId = $reg['player_id'];
$matches = $this->db->select('bracket_matches', [
'phase_id' => "eq.{$phaseId}",
'status' => 'eq.completed',
]);
$wins = 0;
$draws = 0;
$losses = 0;
$played = 0;
foreach ($matches as $m) {
if ($m['player_a_id'] === $playerId || $m['player_b_id'] === $playerId) {
$played++;
if ($m['winner_id'] === $playerId) {
$wins++;
} elseif ($m['result'] === 'draw') {
$draws++;
} else {
$losses++;
}
}
}
$score = ($wins * 2) + $draws;
$standings[] = [
'id' => $playerId,
'name' => $reg['player_name'] ?? $playerId,
'played' => $played,
'wins' => $wins,
'draws' => $draws,
'losses' => $losses,
'score' => $score,
];
}
usort($standings, fn($a, $b) => $b['score'] <=> $a['score'] ?: $b['wins'] <=> $a['wins']);
$rank = 1;
foreach ($standings as &$s) {
$s['rank'] = $rank++;
}
return $standings;
}
public function isArenaActive(string $phaseId): bool
{
$bracket = $this->db->selectOne('tournament_brackets', [
'phase_id' => "eq.{$phaseId}",
]);
if (!$bracket) return false;
$config = json_decode($bracket['config'] ?? '{}', true);
if (($config['type'] ?? '') !== 'arena') return false;
$startedAt = $config['started_at'] ?? null;
$duration = $config['duration_minutes'] ?? 60;
if (!$startedAt) return false;
$endTime = strtotime($startedAt) + ($duration * 60);
return time() < $endTime;
}
public function getArenaInfo(string $phaseId): array
{
$bracket = $this->db->selectOne('tournament_brackets', [
'phase_id' => "eq.{$phaseId}",
]);
if (!$bracket) return [];
$config = json_decode($bracket['config'] ?? '{}', true);
$startedAt = $config['started_at'] ?? null;
$duration = $config['duration_minutes'] ?? 60;
$endTime = $startedAt ? strtotime($startedAt) + ($duration * 60) : 0;
$activeMatches = $this->db->select('bracket_matches', [
'phase_id' => "eq.{$phaseId}",
'status' => 'eq.in_progress',
]);
$completedMatches = $this->db->count('bracket_matches', [
'phase_id' => "eq.{$phaseId}",
'status' => 'eq.completed',
]);
return [
'is_active' => $this->isArenaActive($phaseId),
'started_at' => $startedAt,
'duration_minutes' => $duration,
'ends_at' => $endTime ? date('c', $endTime) : null,
'remaining_seconds' => max(0, $endTime - time()),
'active_matches' => $activeMatches,
'active_matches_count' => count($activeMatches),
'completed_matches_count' => $completedMatches,
];
}
private function getArenaScore(string $phaseId, string $playerId): int
{
$matches = $this->db->select('bracket_matches', [
'phase_id' => "eq.{$phaseId}",
'status' => 'eq.completed',
]);
$score = 0;
foreach ($matches as $m) {
if ($m['winner_id'] === $playerId) {
$score += 2;
} elseif ($m['result'] === 'draw' && ($m['player_a_id'] === $playerId || $m['player_b_id'] === $playerId)) {
$score += 1;
}
}
return $score;
}
}
This diff is collapsed.
<?php
class ExportService
{
public static function downloadTRF(string $swissTournamentId): void
{
$response = SwissApiService::exportTRF($swissTournamentId);
if (!SwissApiService::isSuccess($response)) {
http_response_code(500);
echo 'Export failed: ' . SwissApiService::getError($response);
return;
}
$content = is_array($response['body']) ? json_encode($response['body']) : $response['body'];
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="tournament_' . $swissTournamentId . '.trf"');
header('Content-Length: ' . strlen($content));
echo $content;
exit;
}
public static function downloadJSON(string $swissTournamentId): void
{
$response = SwissApiService::exportJSON($swissTournamentId);
if (!SwissApiService::isSuccess($response)) {
http_response_code(500);
echo 'Export failed: ' . SwissApiService::getError($response);
return;
}
$content = json_encode($response['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="tournament_' . $swissTournamentId . '.json"');
header('Content-Length: ' . strlen($content));
echo $content;
exit;
}
public static function downloadCrosstable(string $swissTournamentId): void
{
$response = SwissApiService::exportCrosstable($swissTournamentId);
if (!SwissApiService::isSuccess($response)) {
http_response_code(500);
echo 'Export failed: ' . SwissApiService::getError($response);
return;
}
$content = is_array($response['body']) ? json_encode($response['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : $response['body'];
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="crosstable_' . $swissTournamentId . '.txt"');
header('Content-Length: ' . strlen($content));
echo $content;
exit;
}
public static function downloadLocalBracket(string $tournamentId): void
{
$db = Database::getInstance();
$tournament = $db->selectOne('el3ab_tournaments', ['id' => "eq.{$tournamentId}"]);
$phases = $db->select('tournament_phases', [
'tournament_id' => "eq.{$tournamentId}",
'order' => 'phase_number.asc',
]);
$brackets = $db->select('tournament_brackets', [
'tournament_id' => "eq.{$tournamentId}",
]);
$matches = $db->select('bracket_matches', [
'tournament_id' => "eq.{$tournamentId}",
'order' => 'round_number.asc,match_number.asc',
]);
$export = [
'tournament' => $tournament,
'phases' => $phases,
'brackets' => $brackets,
'matches' => $matches,
'exported_at' => date('c'),
];
$content = json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="tournament_full_' . $tournamentId . '.json"');
header('Content-Length: ' . strlen($content));
echo $content;
exit;
}
}
This diff is collapsed.
<?php
class SwissApiService
{
// --- Organizations ---
public static function createOrganization(string $name, string $contactEmail): array
{
return ApiProxy::swiss('POST', '/organizations', [
'name' => $name,
'contactEmail' => $contactEmail,
]);
}
public static function getOrganization(string $orgId): array
{
return ApiProxy::swiss('GET', '/organizations/' . $orgId);
}
public static function listOrganizations(): array
{
return ApiProxy::swiss('GET', '/organizations');
}
// --- Events ---
public static function createEvent(string $orgId, string $name, string $dateStart, ?string $dateEnd = null): array
{
return ApiProxy::swiss('POST', '/organizations/' . $orgId . '/events', [
'name' => $name,
'dateStart' => $dateStart,
'dateEnd' => $dateEnd ?? $dateStart,
]);
}
public static function getEvent(string $eventId): array
{
return ApiProxy::swiss('GET', '/events/' . $eventId);
}
public static function listEvents(string $orgId): array
{
return ApiProxy::swiss('GET', '/organizations/' . $orgId . '/events');
}
// --- Tournaments ---
public static function createTournament(string $eventId, array $data): array
{
return ApiProxy::swiss('POST', '/events/' . $eventId . '/tournaments', $data);
}
public static function getTournament(string $tournamentId): array
{
return ApiProxy::swiss('GET', '/tournaments/' . $tournamentId);
}
public static function updateTournament(string $tournamentId, array $data): array
{
return ApiProxy::swiss('PATCH', '/tournaments/' . $tournamentId, $data);
}
public static function deleteTournament(string $tournamentId): array
{
return ApiProxy::swiss('DELETE', '/tournaments/' . $tournamentId);
}
// --- Players ---
public static function registerPlayer(string $tournamentId, array $playerData): array
{
return ApiProxy::swiss('POST', '/tournaments/' . $tournamentId . '/players', $playerData);
}
public static function bulkImportPlayers(string $tournamentId, array $players): array
{
return ApiProxy::swiss('POST', '/tournaments/' . $tournamentId . '/players/import', [
'players' => $players,
]);
}
public static function listPlayers(string $tournamentId): array
{
return ApiProxy::swiss('GET', '/tournaments/' . $tournamentId . '/players');
}
public static function withdrawPlayer(string $tournamentId, string $playerId): array
{
return ApiProxy::swiss('POST', '/tournaments/' . $tournamentId . '/players/' . $playerId . '/withdraw');
}
public static function updatePlayer(string $tournamentId, string $playerId, array $data): array
{
return ApiProxy::swiss('PATCH', '/tournaments/' . $tournamentId . '/players/' . $playerId, $data);
}
// --- Categories ---
public static function createCategory(string $tournamentId, array $data): array
{
return ApiProxy::swiss('POST', '/tournaments/' . $tournamentId . '/categories', $data);
}
public static function listCategories(string $tournamentId): array
{
return ApiProxy::swiss('GET', '/tournaments/' . $tournamentId . '/categories');
}
public static function deleteCategory(string $tournamentId, string $categoryId): array
{
return ApiProxy::swiss('DELETE', '/tournaments/' . $tournamentId . '/categories/' . $categoryId);
}
// --- Rounds ---
public static function generateRound(string $tournamentId): array
{
return ApiProxy::swiss('POST', '/tournaments/' . $tournamentId . '/rounds/generate');
}
public static function listRounds(string $tournamentId): array
{
return ApiProxy::swiss('GET', '/tournaments/' . $tournamentId . '/rounds');
}
public static function getRound(string $roundId): array
{
return ApiProxy::swiss('GET', '/rounds/' . $roundId);
}
public static function unpairRound(string $roundId): array
{
return ApiProxy::swiss('DELETE', '/rounds/' . $roundId . '/pairings');
}
// --- Pairings ---
public static function getPairings(string $roundId): array
{
return ApiProxy::swiss('GET', '/rounds/' . $roundId . '/pairings');
}
public static function createManualPairing(string $roundId, string $playerAId, string $playerBId): array
{
return ApiProxy::swiss('POST', '/rounds/' . $roundId . '/pairings', [
'playerAId' => $playerAId,
'playerBId' => $playerBId,
]);
}
public static function deletePairing(string $pairingId): array
{
return ApiProxy::swiss('DELETE', '/pairings/' . $pairingId);
}
// --- Results ---
public static function submitResult(string $pairingId, string $result): array
{
return ApiProxy::swiss('PATCH', '/pairings/' . $pairingId, [
'result' => $result,
]);
}
public static function submitBatchResults(string $roundId, array $results): array
{
return ApiProxy::swiss('POST', '/rounds/' . $roundId . '/pairings/results', [
'results' => $results,
]);
}
// --- Standings ---
public static function getStandings(string $tournamentId, ?int $round = null, ?string $categoryId = null): array
{
$params = [];
if ($round !== null) {
$params['round'] = $round;
}
if ($categoryId !== null) {
$params['categoryId'] = $categoryId;
}
$query = $params ? '?' . http_build_query($params) : '';
return ApiProxy::swiss('GET', '/tournaments/' . $tournamentId . '/standings' . $query);
}
public static function recalculateStandings(string $tournamentId): array
{
return ApiProxy::swiss('POST', '/tournaments/' . $tournamentId . '/standings/recalculate');
}
// --- Tiebreaks ---
public static function updateTiebreaks(string $tournamentId, array $tiebreaks): array
{
return ApiProxy::swiss('PATCH', '/tournaments/' . $tournamentId, [
'tiebreakRules' => $tiebreaks,
]);
}
// --- Export ---
public static function exportTRF(string $tournamentId): array
{
return ApiProxy::swiss('GET', '/tournaments/' . $tournamentId . '/export/trf');
}
public static function exportJSON(string $tournamentId): array
{
return ApiProxy::swiss('GET', '/tournaments/' . $tournamentId . '/export/json');
}
public static function exportCrosstable(string $tournamentId): array
{
return ApiProxy::swiss('GET', '/tournaments/' . $tournamentId . '/export/crosstable');
}
// --- Helpers ---
public static function isSuccess(array $response): bool
{
return $response['status'] >= 200 && $response['status'] < 300;
}
public static function getError(array $response): string
{
if (is_array($response['body']) && isset($response['body']['message'])) {
return $response['body']['message'];
}
return $response['error'] ?? 'Unknown API error';
}
public static function getBody(array $response): mixed
{
return $response['body'] ?? null;
}
}
<?php
/**
* Arbiter control panel partial
* Expects: $tournament, $rounds
*/
?>
<div class="arbiter-tools">
<div class="grid grid-2 gap-4">
<!-- Categories -->
<?php include __DIR__ . '/_categories.php'; ?>
<!-- Tiebreaks -->
<?php include __DIR__ . '/_tiebreak_config.php'; ?>
</div>
<!-- Export -->
<div class="mt-4">
<?php include __DIR__ . '/_export_panel.php'; ?>
</div>
<!-- Bulk Import -->
<div class="mt-4">
<?php include __DIR__ . '/_player_import.php'; ?>
</div>
<!-- Round Controls -->
<?php if ($tournament['status'] === 'in_progress' && !empty($tournament['swiss_api_tournament_id'])): ?>
<div class="card mt-4">
<h3 class="card-title">أدوات الجولات</h3>
<div class="p-4">
<div class="flex gap-3 flex-wrap">
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/rounds/generate" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/></svg>
إنشاء جولة جديدة
</button>
</form>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/standings/recalculate" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-ghost" onclick="return confirm('إعادة حساب الترتيب؟')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
إعادة حساب الترتيب
</button>
</form>
</div>
<?php if (!empty($rounds)): ?>
<div class="mt-4">
<h4 class="text-sm font-medium mb-2">إلغاء مواجهات جولة:</h4>
<div class="flex gap-2 flex-wrap">
<?php foreach ($rounds as $round): ?>
<?php if ($round['status'] !== 'completed'): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/rounds/<?= $round['id'] ?>/unpair" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-xs btn-danger" onclick="return confirm('إلغاء مواجهات الجولة <?= $round['round_number'] ?>؟')">
إلغاء الجولة <?= $round['round_number'] ?>
</button>
</form>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php
/**
* Arena live view partial
* Expects: $tournament, $phases
*/
$arenaPhase = null;
foreach (($phases ?? []) as $p) {
if ($p['type'] === 'arena') {
$arenaPhase = $p;
break;
}
}
?>
<div class="arena-board" id="arenaBoard" data-tournament-id="<?= $tournament['id'] ?>">
<?php if (!$arenaPhase): ?>
<div class="empty-state">
<h3 class="empty-state-title">لا يوجد وضع أرينا</h3>
<p class="empty-state-text">لم يتم تفعيل وضع الأرينا في هذه البطولة</p>
</div>
<?php else: ?>
<!-- Arena Timer -->
<div class="arena-header card p-4 mb-4">
<div class="arena-timer-row">
<div class="arena-status">
<span class="arena-status-dot" id="arenaStatusDot"></span>
<span id="arenaStatusText">جاري التحميل...</span>
</div>
<div class="arena-timer" id="arenaTimer">--:--:--</div>
</div>
<div class="arena-stats mt-3">
<div class="arena-stat">
<span class="arena-stat-value" id="arenaActiveCount">0</span>
<span class="arena-stat-label">مباريات جارية</span>
</div>
<div class="arena-stat">
<span class="arena-stat-value" id="arenaCompletedCount">0</span>
<span class="arena-stat-label">مكتملة</span>
</div>
<div class="arena-stat">
<span class="arena-stat-value" id="arenaPlayersCount">0</span>
<span class="arena-stat-label">لاعبون</span>
</div>
</div>
<?php if ($tournament['status'] === 'in_progress'): ?>
<div class="mt-3">
<button type="button" class="btn btn-primary btn-sm" onclick="arenaPairNow()">إنشاء مواجهات جديدة</button>
</div>
<?php endif; ?>
</div>
<!-- Active Matches -->
<div class="card mb-4">
<h4 class="card-title p-4 pb-0">المباريات الجارية</h4>
<div id="arenaActiveMatches" class="p-4">
<div class="text-muted text-center">جاري التحميل...</div>
</div>
</div>
<!-- Arena Standings -->
<div class="card">
<h4 class="card-title p-4 pb-0">ترتيب الأرينا</h4>
<div id="arenaStandings" class="p-4">
<div class="text-muted text-center">جاري التحميل...</div>
</div>
</div>
<?php endif; ?>
</div>
<?php if ($arenaPhase): ?>
<script>
let arenaRefreshInterval = null;
function loadArenaData() {
const tournamentId = '<?= $tournament['id'] ?>';
fetch('/api/tournaments/' + tournamentId + '/arena')
.then(r => r.json())
.then(data => {
updateArenaUI(data);
})
.catch(() => {});
}
function updateArenaUI(data) {
// Status
const dot = document.getElementById('arenaStatusDot');
const statusText = document.getElementById('arenaStatusText');
if (data.is_active) {
dot.className = 'arena-status-dot active';
statusText.textContent = 'الأرينا نشطة';
} else {
dot.className = 'arena-status-dot ended';
statusText.textContent = 'الأرينا انتهت';
clearInterval(arenaRefreshInterval);
}
// Timer
if (data.remaining_seconds !== undefined) {
updateTimer(data.remaining_seconds);
}
// Stats
document.getElementById('arenaActiveCount').textContent = data.active_matches_count || 0;
document.getElementById('arenaCompletedCount').textContent = data.completed_matches_count || 0;
document.getElementById('arenaPlayersCount').textContent = (data.standings || []).length;
// Active Matches
const matchesEl = document.getElementById('arenaActiveMatches');
const matches = data.active_matches || [];
if (matches.length === 0) {
matchesEl.innerHTML = '<div class="text-muted text-center">لا توجد مباريات جارية</div>';
} else {
matchesEl.innerHTML = matches.map(m => `
<div class="arena-match-card">
<span class="arena-match-player">${m.player_a_name || 'TBD'}</span>
<span class="arena-match-vs">vs</span>
<span class="arena-match-player">${m.player_b_name || 'TBD'}</span>
<div class="arena-match-actions">
<button class="btn btn-xs btn-success" onclick="submitArenaResult('${m.id}', 'player_a_wins')">فوز أ</button>
<button class="btn btn-xs btn-ghost" onclick="submitArenaResult('${m.id}', 'draw')">تعادل</button>
<button class="btn btn-xs btn-success" onclick="submitArenaResult('${m.id}', 'player_b_wins')">فوز ب</button>
</div>
</div>
`).join('');
}
// Standings
const standingsEl = document.getElementById('arenaStandings');
const standings = data.standings || [];
if (standings.length === 0) {
standingsEl.innerHTML = '<div class="text-muted text-center">لا توجد نتائج بعد</div>';
} else {
standingsEl.innerHTML = `
<table class="data-table data-table-sm">
<thead><tr><th>#</th><th>اللاعب</th><th>لعب</th><th>ف</th><th>ت</th><th>خ</th><th>نقاط</th></tr></thead>
<tbody>${standings.map(s => `
<tr class="${s.rank <= 3 ? 'standing-top' : ''}">
<td><span class="standing-rank ${s.rank === 1 ? 'rank-gold' : s.rank === 2 ? 'rank-silver' : s.rank === 3 ? 'rank-bronze' : ''}">${s.rank}</span></td>
<td class="font-medium">${s.name}</td>
<td class="tabular-nums">${s.played}</td>
<td class="tabular-nums">${s.wins}</td>
<td class="tabular-nums">${s.draws}</td>
<td class="tabular-nums">${s.losses}</td>
<td class="tabular-nums font-medium">${s.score}</td>
</tr>
`).join('')}</tbody>
</table>
`;
}
}
function updateTimer(seconds) {
const el = document.getElementById('arenaTimer');
if (seconds <= 0) {
el.textContent = '00:00:00';
el.classList.add('timer-ended');
return;
}
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
el.textContent = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function arenaPairNow() {
const tournamentId = '<?= $tournament['id'] ?>';
fetch('/tournaments/' + tournamentId + '/arena/pair', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: '_csrf=<?= Auth::csrfToken() ?>'
})
.then(r => r.json())
.then(data => {
if (data.error) alert(data.error);
else loadArenaData();
});
}
function submitArenaResult(matchId, result) {
const tournamentId = '<?= $tournament['id'] ?>';
fetch('/tournaments/' + tournamentId + '/arena/result', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `_csrf=<?= Auth::csrfToken() ?>&match_id=${matchId}&result=${result}`
})
.then(r => r.json())
.then(data => {
if (data.error) alert(data.error);
else loadArenaData();
});
}
document.addEventListener('DOMContentLoaded', () => {
loadArenaData();
arenaRefreshInterval = setInterval(loadArenaData, 5000);
});
</script>
<?php endif; ?>
<?php
/**
* Bracket tree visualization partial
* Expects: $brackets (array of bracket states from BracketEngine::getBracketState)
* $tournament (tournament record)
*/
$bracketData = $brackets ?? [];
?>
<div class="bracket-container" id="bracketContainer">
<?php if (empty($bracketData)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h6v6H4zM14 4h6v6h-6zM9 14h6v6H9z"/>
<path d="M7 10v2h5M17 10v2h-5M12 12v2"/>
</svg>
<h3 class="empty-state-title">لا يوجد قوسية</h3>
<p class="empty-state-text">ستظهر القوسية عند بدء مرحلة الإقصاء</p>
</div>
<?php else: ?>
<?php foreach ($bracketData as $bracketState): ?>
<?php
$bracket = $bracketState['bracket'] ?? [];
$rounds = $bracketState['rounds'] ?? [];
$totalRounds = $bracketState['total_rounds'] ?? 0;
$bracketType = $bracket['bracket_type'] ?? 'winners';
$roundLabels = [];
for ($r = 1; $r <= $totalRounds; $r++) {
if ($r === $totalRounds) $roundLabels[$r] = 'النهائي';
elseif ($r === $totalRounds - 1) $roundLabels[$r] = 'نصف النهائي';
elseif ($r === $totalRounds - 2) $roundLabels[$r] = 'ربع النهائي';
else $roundLabels[$r] = 'الجولة ' . $r;
}
?>
<div class="bracket-section" data-bracket-id="<?= $bracket['id'] ?? '' ?>">
<h4 class="bracket-title">
<?php if ($bracketType === 'winners'): ?>قوسية الفائزين<?php endif; ?>
<?php if ($bracketType === 'losers'): ?>قوسية الخاسرين<?php endif; ?>
<?php if ($bracketType === 'group'): ?>المجموعة <?= View::e($bracket['group_name'] ?? '') ?><?php endif; ?>
</h4>
<div class="bracket-rounds" data-total-rounds="<?= $totalRounds ?>">
<?php for ($round = 1; $round <= $totalRounds; $round++): ?>
<div class="bracket-round" data-round="<?= $round ?>">
<div class="bracket-round-header"><?= $roundLabels[$round] ?? "R{$round}" ?></div>
<div class="bracket-round-matches">
<?php foreach (($rounds[$round] ?? []) as $match): ?>
<div class="bracket-match <?= $match['status'] ?>" data-match-id="<?= $match['id'] ?>">
<div class="bracket-player bracket-player-a <?= $match['result'] === 'player_a_wins' ? 'winner' : '' ?>">
<span class="bracket-seed"><?= $match['player_a_seed'] ?? '' ?></span>
<span class="bracket-name"><?= View::e($match['player_a_name'] ?? 'TBD') ?></span>
<span class="bracket-score"><?= $match['score_a'] ?? '' ?></span>
</div>
<div class="bracket-player bracket-player-b <?= $match['result'] === 'player_b_wins' ? 'winner' : '' ?>">
<span class="bracket-seed"><?= $match['player_b_seed'] ?? '' ?></span>
<span class="bracket-name"><?= View::e($match['player_b_name'] ?? 'TBD') ?></span>
<span class="bracket-score"><?= $match['score_b'] ?? '' ?></span>
</div>
<?php if ($match['status'] === 'ready' && $tournament['status'] === 'in_progress'): ?>
<button type="button" class="bracket-result-btn" onclick="openBracketResultModal('<?= $match['id'] ?>', '<?= View::e($match['player_a_name'] ?? 'A') ?>', '<?= View::e($match['player_b_name'] ?? 'B') ?>')">
إدخال النتيجة
</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endfor; ?>
</div>
</div>
<?php endforeach; ?>
<!-- SVG connector layer -->
<svg class="bracket-connectors" id="bracketConnectors"></svg>
<?php endif; ?>
</div>
<!-- Bracket Result Modal -->
<div class="modal" id="bracketResultModal" style="display:none;">
<div class="modal-overlay" onclick="closeBracketResultModal()"></div>
<div class="modal-content modal-sm">
<div class="modal-header">
<h3>نتيجة المباراة</h3>
<button type="button" class="btn btn-icon btn-ghost" onclick="closeBracketResultModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form method="POST" id="bracketResultForm" action="">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="modal-body">
<div class="bracket-result-options">
<label class="bracket-result-option">
<input type="radio" name="result" value="player_a_wins" required>
<span id="bracketPlayerAName">اللاعب أ</span> فاز
</label>
<label class="bracket-result-option">
<input type="radio" name="result" value="player_b_wins" required>
<span id="bracketPlayerBName">اللاعب ب</span> فاز
</label>
<label class="bracket-result-option">
<input type="radio" name="result" value="draw">
تعادل
</label>
</div>
<div class="form-row mt-3">
<div class="form-group">
<label>نتيجة أ</label>
<input type="text" name="score_a" class="form-control" placeholder="مثال: 2">
</div>
<div class="form-group">
<label>نتيجة ب</label>
<input type="text" name="score_b" class="form-control" placeholder="مثال: 1">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeBracketResultModal()">إلغاء</button>
<button type="submit" class="btn btn-primary">حفظ</button>
</div>
</form>
</div>
</div>
<?php
/**
* Category management partial
* Expects: $tournament
*/
?>
<div class="categories-section">
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="card-title">الفئات</h3>
<button type="button" class="btn btn-sm btn-primary" onclick="toggleCategoryForm()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إضافة فئة
</button>
</div>
<!-- Add Category Form -->
<div id="categoryForm" style="display:none;" class="p-4 border-b">
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/categories/store">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="grid grid-3 gap-3">
<div class="form-group">
<label>اسم الفئة</label>
<input type="text" name="name" class="form-control" required placeholder="مثال: تحت 18">
</div>
<div class="form-group">
<label>أقل تقييم</label>
<input type="number" name="min_rating" class="form-control" placeholder="0">
</div>
<div class="form-group">
<label>أعلى تقييم</label>
<input type="number" name="max_rating" class="form-control" placeholder="3000">
</div>
<div class="form-group">
<label>أقل عمر</label>
<input type="number" name="min_age" class="form-control" placeholder="0">
</div>
<div class="form-group">
<label>أعلى عمر</label>
<input type="number" name="max_age" class="form-control" placeholder="99">
</div>
<div class="form-group">
<label>الجنس</label>
<select name="gender" class="form-control">
<option value="">الكل</option>
<option value="male">ذكر</option>
<option value="female">أنثى</option>
</select>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">حفظ الفئة</button>
<button type="button" class="btn btn-ghost btn-sm" onclick="toggleCategoryForm()">إلغاء</button>
</div>
</form>
</div>
<!-- Categories List (loaded via AJAX) -->
<div id="categoriesList" class="p-4">
<div class="loading-spinner" id="categoriesLoading">
<div class="spinner"></div>
<span>جاري التحميل...</span>
</div>
<table class="data-table" id="categoriesTable" style="display:none;">
<thead>
<tr>
<th>الاسم</th>
<th>التقييم</th>
<th>العمر</th>
<th>الجنس</th>
<th></th>
</tr>
</thead>
<tbody id="categoriesBody">
</tbody>
</table>
<div id="categoriesEmpty" style="display:none;" class="text-center text-muted p-4">
لا توجد فئات. أضف فئة لتصنيف اللاعبين.
</div>
</div>
</div>
</div>
<script>
function toggleCategoryForm() {
const form = document.getElementById('categoryForm');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
function loadCategories() {
const tournamentId = '<?= $tournament['id'] ?>';
fetch('/tournaments/' + tournamentId + '/categories')
.then(r => r.json())
.then(data => {
document.getElementById('categoriesLoading').style.display = 'none';
const categories = data.categories || [];
if (categories.length === 0) {
document.getElementById('categoriesEmpty').style.display = 'block';
return;
}
document.getElementById('categoriesTable').style.display = 'table';
const tbody = document.getElementById('categoriesBody');
tbody.innerHTML = categories.map(c => `
<tr>
<td class="font-medium">${c.name || '-'}</td>
<td class="tabular-nums">${c.minRating || 0} - ${c.maxRating || '∞'}</td>
<td class="tabular-nums">${c.minAge || 0} - ${c.maxAge || '∞'}</td>
<td>${c.gender || 'الكل'}</td>
<td>
<form method="POST" action="/tournaments/${tournamentId}/categories/${c.id}/delete" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-icon btn-xs btn-ghost text-danger" onclick="return confirm('حذف الفئة؟')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</form>
</td>
</tr>
`).join('');
})
.catch(() => {
document.getElementById('categoriesLoading').innerHTML = '<span class="text-danger">فشل تحميل الفئات</span>';
});
}
document.addEventListener('DOMContentLoaded', loadCategories);
</script>
<?php
/**
* Export buttons panel partial
* Expects: $tournament
*/
$hasSwissLink = !empty($tournament['swiss_api_tournament_id']);
?>
<div class="export-panel card">
<h3 class="card-title">تصدير البيانات</h3>
<div class="export-buttons">
<?php if ($hasSwissLink): ?>
<a href="/tournaments/<?= $tournament['id'] ?>/export/trf" class="btn btn-ghost export-btn" download>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span>
<strong>TRF</strong>
<small>ملف FIDE Tournament Report</small>
</span>
</a>
<a href="/tournaments/<?= $tournament['id'] ?>/export/json" class="btn btn-ghost export-btn" download>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h2M8 17h2"/></svg>
<span>
<strong>JSON</strong>
<small>بيانات كاملة</small>
</span>
</a>
<a href="/tournaments/<?= $tournament['id'] ?>/export/crosstable" class="btn btn-ghost export-btn" download>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
<span>
<strong>Crosstable</strong>
<small>جدول المواجهات المتبادلة</small>
</span>
</a>
<?php endif; ?>
<a href="/tournaments/<?= $tournament['id'] ?>/export/full" class="btn btn-ghost export-btn" download>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>
<strong>تصدير كامل</strong>
<small>جميع البيانات المحلية</small>
</span>
</a>
</div>
</div>
<?php
/**
* Group stage tables partial
* Expects: $brackets (array of group brackets), $tournament
*/
$groupBrackets = array_filter($brackets ?? [], fn($b) => ($b['bracket_type'] ?? '') === 'group');
$bracketEngine = new BracketEngine();
?>
<div class="groups-container">
<?php if (empty($groupBrackets)): ?>
<div class="empty-state">
<h3 class="empty-state-title">لا توجد مجموعات</h3>
<p class="empty-state-text">ستظهر المجموعات عند بدء مرحلة المجموعات</p>
</div>
<?php else: ?>
<div class="grid grid-2 gap-4">
<?php foreach ($groupBrackets as $bracket): ?>
<?php $standings = $bracketEngine->getGroupStandings($bracket['id']); ?>
<div class="card group-card">
<h4 class="card-title">المجموعة <?= View::e($bracket['group_name'] ?? '') ?></h4>
<table class="data-table data-table-sm">
<thead>
<tr>
<th>#</th>
<th>اللاعب</th>
<th>لعب</th>
<th>ف</th>
<th>ت</th>
<th>خ</th>
<th>نقاط</th>
</tr>
</thead>
<tbody>
<?php
$config = json_decode($bracket['config'] ?? '{}', true);
$advanceCount = $config['advance_count'] ?? 2;
?>
<?php foreach ($standings as $i => $s): ?>
<tr class="<?= $i < $advanceCount ? 'group-qualified' : '' ?>">
<td><?= $s['rank'] ?></td>
<td class="font-medium"><?= View::e($s['name'] ?? '-') ?></td>
<td class="tabular-nums"><?= $s['played'] ?></td>
<td class="tabular-nums"><?= $s['wins'] ?></td>
<td class="tabular-nums"><?= $s['draws'] ?></td>
<td class="tabular-nums"><?= $s['losses'] ?></td>
<td class="tabular-nums font-medium"><?= $s['points'] ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
$matches = Database::getInstance()->select('bracket_matches', [
'bracket_id' => "eq.{$bracket['id']}",
'status' => 'eq.ready',
]);
?>
<?php if (!empty($matches) && $tournament['status'] === 'in_progress'): ?>
<div class="group-matches mt-3">
<h5 class="text-sm text-muted mb-2">مباريات جاهزة</h5>
<?php foreach ($matches as $match): ?>
<div class="group-match-row">
<span><?= View::e($match['player_a_name'] ?? 'TBD') ?></span>
<span class="vs">vs</span>
<span><?= View::e($match['player_b_name'] ?? 'TBD') ?></span>
<button type="button" class="btn btn-xs btn-primary" onclick="openBracketResultModal('<?= $match['id'] ?>', '<?= View::e($match['player_a_name'] ?? 'A') ?>', '<?= View::e($match['player_b_name'] ?? 'B') ?>')">
نتيجة
</button>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php
/**
* Detailed pairings table with arbiter tools
* Expects: $round (round record), $tournament, $pairingsData (from Swiss API)
*/
$pairingsData = $pairingsData ?? [];
$resultLabels = [
'1-0' => 'أبيض فاز',
'0-1' => 'أسود فاز',
'0.5-0.5' => 'تعادل',
'1-0F' => 'غياب أسود',
'0-1F' => 'غياب أبيض',
'0-0F' => 'غياب مزدوج',
];
?>
<div class="pairings-section">
<div class="pairings-header">
<h4>مواجهات الجولة <?= $round['round_number'] ?? '' ?></h4>
<?php if (($round['status'] ?? '') !== 'completed' && $tournament['status'] === 'in_progress'): ?>
<div class="pairings-actions">
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/rounds/<?= $round['id'] ?>/unpair" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-xs btn-danger" onclick="return confirm('هل أنت متأكد من إلغاء جميع المواجهات؟')">
إلغاء المواجهات
</button>
</form>
<button type="button" class="btn btn-xs btn-ghost" onclick="toggleManualPairing()">
مواجهة يدوية
</button>
</div>
<?php endif; ?>
</div>
<!-- Manual Pairing Form (hidden) -->
<div class="manual-pairing-form card p-3 mb-3" id="manualPairingForm" style="display:none;">
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/rounds/<?= $round['id'] ?? '' ?>/pairings/manual">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-row">
<div class="form-group">
<label>اللاعب الأول</label>
<select name="player_a_id" class="form-control" required>
<option value="">اختر لاعب...</option>
<?php foreach ($players ?? [] as $p): ?>
<option value="<?= $p['player_id'] ?>"><?= View::e($p['player_name'] ?? $p['player_id']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>اللاعب الثاني</label>
<select name="player_b_id" class="form-control" required>
<option value="">اختر لاعب...</option>
<?php foreach ($players ?? [] as $p): ?>
<option value="<?= $p['player_id'] ?>"><?= View::e($p['player_name'] ?? $p['player_id']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="align-self:flex-end;">
<button type="submit" class="btn btn-primary btn-sm">إنشاء</button>
</div>
</div>
</form>
</div>
<?php if (!empty($pairingsData)): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/rounds/<?= $round['id'] ?? '' ?>/results" id="pairingsResultsForm">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="round_id" value="<?= $round['id'] ?? '' ?>">
<table class="data-table">
<thead>
<tr>
<th class="w-10">#</th>
<th>الأبيض</th>
<th class="text-center">التقييم</th>
<th class="text-center w-32">النتيجة</th>
<th class="text-center">التقييم</th>
<th>الأسود</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody>
<?php foreach ($pairingsData as $i => $pairing): ?>
<tr>
<td class="tabular-nums"><?= $i + 1 ?></td>
<td class="font-medium"><?= View::e($pairing['playerA']['name'] ?? $pairing['white']['name'] ?? '-') ?></td>
<td class="text-center tabular-nums text-muted"><?= $pairing['playerA']['rating'] ?? $pairing['white']['rating'] ?? '-' ?></td>
<td class="text-center">
<?php $pId = $pairing['id'] ?? $i; ?>
<?php if (($round['status'] ?? '') !== 'completed'): ?>
<select name="results[<?= $pId ?>]" class="form-control form-control-sm text-center">
<option value="">-</option>
<option value="1-0" <?= ($pairing['result'] ?? '') === '1-0' ? 'selected' : '' ?>>1 - 0</option>
<option value="0.5-0.5" <?= ($pairing['result'] ?? '') === '0.5-0.5' ? 'selected' : '' ?>>½ - ½</option>
<option value="0-1" <?= ($pairing['result'] ?? '') === '0-1' ? 'selected' : '' ?>>0 - 1</option>
<option value="1-0F" <?= ($pairing['result'] ?? '') === '1-0F' ? 'selected' : '' ?>>1-0F</option>
<option value="0-1F" <?= ($pairing['result'] ?? '') === '0-1F' ? 'selected' : '' ?>>0-1F</option>
</select>
<?php else: ?>
<span class="badge badge-default"><?= $resultLabels[$pairing['result'] ?? ''] ?? ($pairing['result'] ?? '-') ?></span>
<?php endif; ?>
</td>
<td class="text-center tabular-nums text-muted"><?= $pairing['playerB']['rating'] ?? $pairing['black']['rating'] ?? '-' ?></td>
<td class="font-medium"><?= View::e($pairing['playerB']['name'] ?? $pairing['black']['name'] ?? '-') ?></td>
<td>
<?php if (($round['status'] ?? '') !== 'completed' && !empty($pairing['id'])): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/pairings/<?= $pairing['id'] ?>/delete" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-icon btn-xs btn-ghost text-danger" onclick="return confirm('حذف المواجهة؟')" title="حذف">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (($round['status'] ?? '') !== 'completed'): ?>
<div class="mt-3 flex gap-2">
<button type="submit" class="btn btn-primary">حفظ جميع النتائج</button>
</div>
<?php endif; ?>
</form>
<?php else: ?>
<div class="empty-state p-4">
<p class="text-muted">لا توجد مواجهات في هذه الجولة</p>
</div>
<?php endif; ?>
</div>
<?php
/**
* Phase progress timeline partial
* Expects: $phases (array), $tournament
*/
$currentPhase = $tournament['current_phase'] ?? 1;
$phaseTypeLabels = [
'swiss' => 'سويسري',
'round_robin' => 'دوري كامل',
'single_elimination' => 'إقصاء مباشر',
'double_elimination' => 'إقصاء مزدوج',
'arena' => 'أرينا',
'group_stage' => 'مجموعات',
];
$phaseStatusIcons = [
'pending' => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>',
'in_progress' => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
'completed' => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
];
?>
<div class="phase-timeline">
<?php foreach ($phases as $i => $phase): ?>
<div class="phase-step <?= $phase['status'] ?>" data-phase-id="<?= $phase['id'] ?>">
<div class="phase-step-indicator">
<div class="phase-step-circle">
<?= $phaseStatusIcons[$phase['status']] ?? '' ?>
</div>
<?php if ($i < count($phases) - 1): ?>
<div class="phase-step-connector <?= $phase['status'] === 'completed' ? 'completed' : '' ?>"></div>
<?php endif; ?>
</div>
<div class="phase-step-content">
<h4 class="phase-step-title"><?= View::e($phase['name_ar'] ?? $phase['name']) ?></h4>
<span class="phase-step-type badge badge-sm"><?= $phaseTypeLabels[$phase['type']] ?? $phase['type'] ?></span>
<?php if ($phase['status'] === 'in_progress'): ?>
<span class="badge badge-warning badge-sm">جارية</span>
<?php elseif ($phase['status'] === 'completed'): ?>
<span class="badge badge-success badge-sm">مكتملة</span>
<?php endif; ?>
<?php if ($phase['status'] === 'in_progress' && $tournament['status'] === 'in_progress'): ?>
<div class="phase-actions mt-2">
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/phases/<?= $phase['id'] ?>/complete" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-xs btn-success" onclick="return confirm('هل أنت متأكد من إكمال هذه المرحلة؟')">إكمال المرحلة</button>
</form>
</div>
<?php elseif ($phase['status'] === 'completed' && isset($phases[$i + 1]) && $phases[$i + 1]['status'] === 'pending'): ?>
<div class="phase-actions mt-2">
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/phases/<?= $phase['id'] ?>/advance" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-xs btn-primary">ترقية اللاعبين</button>
</form>
</div>
<?php elseif ($phase['status'] === 'pending' && ($i === 0 || ($phases[$i - 1]['status'] ?? '') === 'completed')): ?>
<div class="phase-actions mt-2">
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/phases/<?= $phase['id'] ?>/start" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-xs btn-primary">بدء المرحلة</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
/**
* Bulk player import partial (CSV/paste)
* Expects: $tournament
*/
?>
<div class="player-import-section card">
<h3 class="card-title">استيراد لاعبين</h3>
<div class="import-tabs">
<button type="button" class="import-tab active" onclick="switchImportTab('csv')">رفع CSV</button>
<button type="button" class="import-tab" onclick="switchImportTab('paste')">لصق بيانات</button>
</div>
<!-- CSV Upload -->
<div id="importTabCsv" class="import-tab-content active">
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/players/import" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-group">
<label>ملف CSV</label>
<input type="file" name="csv_file" accept=".csv,.txt" class="form-control" required>
<small class="text-muted">يجب أن يحتوي على أعمدة: name, rating (اختياري), fide_id (اختياري)</small>
</div>
<div class="csv-format-help mt-2 p-3 bg-muted rounded">
<strong>نموذج CSV:</strong>
<pre class="text-xs mt-1">name,rating,fide_id
أحمد محمد,1850,12345678
سارة أحمد,1720,
علي حسن,2100,87654321</pre>
</div>
<button type="submit" class="btn btn-primary mt-3">استيراد من CSV</button>
</form>
</div>
<!-- Paste Data -->
<div id="importTabPaste" class="import-tab-content" style="display:none;">
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/players/import">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-group">
<label>بيانات اللاعبين</label>
<textarea name="players_text" class="form-control" rows="10" placeholder="اسم اللاعب التقييم FIDE ID&#10;أحمد محمد 1850 12345678&#10;سارة أحمد 1720"></textarea>
<small class="text-muted">سطر لكل لاعب. افصل الأعمدة بـ Tab أو فاصلة. الترتيب: الاسم، التقييم (اختياري)، FIDE ID (اختياري)</small>
</div>
<button type="submit" class="btn btn-primary">استيراد</button>
</form>
</div>
</div>
<script>
function switchImportTab(tab) {
document.querySelectorAll('.import-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.import-tab-content').forEach(c => { c.style.display = 'none'; c.classList.remove('active'); });
if (tab === 'csv') {
document.getElementById('importTabCsv').style.display = 'block';
document.querySelector('.import-tab:first-child').classList.add('active');
} else {
document.getElementById('importTabPaste').style.display = 'block';
document.querySelector('.import-tab:last-child').classList.add('active');
}
}
</script>
<?php
/**
* Tiebreak configuration with drag-to-reorder
* Expects: $tournament
*/
$currentTiebreaks = json_decode($tournament['tiebreak_rules'] ?? '["buchholz_cut_1","buchholz","sonneborn_berger"]', true);
$availableTiebreaks = [
'buchholz' => 'Buchholz',
'buchholz_cut_1' => 'Buchholz Cut 1',
'buchholz_cut_2' => 'Buchholz Cut 2',
'sonneborn_berger' => 'Sonneborn-Berger',
'progressive' => 'Progressive Score',
'number_of_wins' => 'Number of Wins',
'number_of_blacks' => 'Number of Blacks',
'aro' => 'Average Rating of Opponents',
'aroc' => 'Average Rating of Cut Opponents',
'koya' => 'Koya System',
'direct_encounter' => 'Direct Encounter',
];
?>
<div class="tiebreak-section card">
<h3 class="card-title">قواعد كسر التعادل</h3>
<p class="text-sm text-muted mb-3">اسحب لإعادة ترتيب الأولوية. القاعدة الأولى لها أعلى أولوية.</p>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/tiebreaks/update" id="tiebreakForm">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="tiebreaks" id="tiebreaksInput" value="<?= htmlspecialchars(json_encode($currentTiebreaks)) ?>">
<div class="tiebreak-list" id="tiebreakList">
<?php foreach ($currentTiebreaks as $i => $tb): ?>
<div class="tiebreak-item" draggable="true" data-value="<?= $tb ?>">
<span class="tiebreak-drag-handle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="18" x2="16" y2="18"/></svg>
</span>
<span class="tiebreak-rank"><?= $i + 1 ?></span>
<span class="tiebreak-name"><?= $availableTiebreaks[$tb] ?? $tb ?></span>
<button type="button" class="btn btn-icon btn-xs btn-ghost text-danger" onclick="removeTiebreak(this)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<?php endforeach; ?>
</div>
<div class="tiebreak-add mt-3">
<select id="tiebreakAddSelect" class="form-control form-control-sm" style="display:inline-block;width:auto;">
<option value="">إضافة قاعدة...</option>
<?php foreach ($availableTiebreaks as $key => $label): ?>
<?php if (!in_array($key, $currentTiebreaks)): ?>
<option value="<?= $key ?>"><?= $label ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<button type="button" class="btn btn-sm btn-ghost" onclick="addTiebreak()">إضافة</button>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">حفظ الترتيب</button>
</div>
</form>
</div>
<script>
const tiebreakLabels = <?= json_encode($availableTiebreaks) ?>;
function initTiebreakDrag() {
const list = document.getElementById('tiebreakList');
let draggedItem = null;
list.querySelectorAll('.tiebreak-item').forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedItem = item;
item.classList.add('dragging');
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
draggedItem = null;
updateTiebreakOrder();
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
if (draggedItem && draggedItem !== item) {
const rect = item.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) {
list.insertBefore(draggedItem, item);
} else {
list.insertBefore(draggedItem, item.nextSibling);
}
}
});
});
}
function updateTiebreakOrder() {
const items = document.querySelectorAll('#tiebreakList .tiebreak-item');
const order = [];
items.forEach((item, i) => {
item.querySelector('.tiebreak-rank').textContent = i + 1;
order.push(item.dataset.value);
});
document.getElementById('tiebreaksInput').value = JSON.stringify(order);
}
function addTiebreak() {
const select = document.getElementById('tiebreakAddSelect');
const value = select.value;
if (!value) return;
const list = document.getElementById('tiebreakList');
const count = list.children.length + 1;
const div = document.createElement('div');
div.className = 'tiebreak-item';
div.draggable = true;
div.dataset.value = value;
div.innerHTML = `
<span class="tiebreak-drag-handle"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="18" x2="16" y2="18"/></svg></span>
<span class="tiebreak-rank">${count}</span>
<span class="tiebreak-name">${tiebreakLabels[value] || value}</span>
<button type="button" class="btn btn-icon btn-xs btn-ghost text-danger" onclick="removeTiebreak(this)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
`;
list.appendChild(div);
select.querySelector(`option[value="${value}"]`).remove();
select.value = '';
updateTiebreakOrder();
initTiebreakDrag();
}
function removeTiebreak(btn) {
const item = btn.closest('.tiebreak-item');
const value = item.dataset.value;
item.remove();
const select = document.getElementById('tiebreakAddSelect');
const option = document.createElement('option');
option.value = value;
option.textContent = tiebreakLabels[value] || value;
select.appendChild(option);
updateTiebreakOrder();
}
document.addEventListener('DOMContentLoaded', initTiebreakDrag);
</script>
...@@ -92,6 +92,27 @@ ...@@ -92,6 +92,27 @@
<div class="wizard-panel" data-panel="2"> <div class="wizard-panel" data-panel="2">
<h2 class="form-section-title">نظام البطولة</h2> <h2 class="form-section-title">نظام البطولة</h2>
<!-- Tournament Mode -->
<div class="form-group mb-4">
<label class="form-label">وضع البطولة *</label>
<div class="mode-selector">
<label class="mode-option <?= ($tournament['tournament_mode'] ?? 'single') === 'single' ? 'active' : '' ?>">
<input type="radio" name="tournament_mode" value="single" <?= ($tournament['tournament_mode'] ?? 'single') === 'single' ? 'checked' : '' ?> onchange="togglePhaseDesigner()">
<div class="mode-content">
<strong>مرحلة واحدة</strong>
<small>نظام واحد من البداية للنهاية</small>
</div>
</label>
<label class="mode-option <?= ($tournament['tournament_mode'] ?? '') === 'multi_phase' ? 'active' : '' ?>">
<input type="radio" name="tournament_mode" value="multi_phase" <?= ($tournament['tournament_mode'] ?? '') === 'multi_phase' ? 'checked' : '' ?> onchange="togglePhaseDesigner()">
<div class="mode-content">
<strong>متعدد المراحل</strong>
<small>مراحل متتالية (مثال: سويسري ← إقصاء)</small>
</div>
</label>
</div>
</div>
<div class="grid grid-2 gap-4"> <div class="grid grid-2 gap-4">
<div class="form-group"> <div class="form-group">
<label class="form-label">نظام المنافسة *</label> <label class="form-label">نظام المنافسة *</label>
...@@ -101,6 +122,8 @@ ...@@ -101,6 +122,8 @@
<option value="round_robin" <?= ($tournament['format'] ?? '') === 'round_robin' ? 'selected' : '' ?>>دوري كامل</option> <option value="round_robin" <?= ($tournament['format'] ?? '') === 'round_robin' ? 'selected' : '' ?>>دوري كامل</option>
<option value="single_elimination" <?= ($tournament['format'] ?? '') === 'single_elimination' ? 'selected' : '' ?>>خروج مباشر</option> <option value="single_elimination" <?= ($tournament['format'] ?? '') === 'single_elimination' ? 'selected' : '' ?>>خروج مباشر</option>
<option value="double_elimination" <?= ($tournament['format'] ?? '') === 'double_elimination' ? 'selected' : '' ?>>خروج مزدوج</option> <option value="double_elimination" <?= ($tournament['format'] ?? '') === 'double_elimination' ? 'selected' : '' ?>>خروج مزدوج</option>
<option value="arena" <?= ($tournament['format'] ?? '') === 'arena' ? 'selected' : '' ?>>أرينا</option>
<option value="group_stage" <?= ($tournament['format'] ?? '') === 'group_stage' ? 'selected' : '' ?>>مجموعات</option>
</select> </select>
<span class="form-error"></span> <span class="form-error"></span>
</div> </div>
...@@ -124,6 +147,31 @@ ...@@ -124,6 +147,31 @@
</div> </div>
</div> </div>
<!-- Phase Designer (shown for multi_phase) -->
<div id="phaseDesigner" class="phase-designer mt-4" style="display: <?= ($tournament['tournament_mode'] ?? 'single') === 'multi_phase' ? 'block' : 'none' ?>;">
<h3 class="form-section-title">تصميم المراحل</h3>
<p class="text-sm text-muted mb-3">أضف مراحل البطولة بالترتيب. اللاعبون المتأهلون ينتقلون تلقائياً للمرحلة التالية.</p>
<div id="phasesList" class="phases-list">
<!-- Phases added dynamically -->
</div>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addPhase()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إضافة مرحلة
</button>
<input type="hidden" name="phases" id="phasesInput" value="<?= htmlspecialchars($tournament['phase_config'] ?? '[]') ?>">
<!-- Preset Templates -->
<div class="phase-templates mt-4">
<span class="text-sm text-muted">قوالب جاهزة:</span>
<button type="button" class="btn btn-xs btn-ghost" onclick="loadTemplate('swiss_bracket')">سويسري ← إقصاء</button>
<button type="button" class="btn btn-xs btn-ghost" onclick="loadTemplate('groups_ko')">مجموعات ← إقصاء</button>
<button type="button" class="btn btn-xs btn-ghost" onclick="loadTemplate('swiss_groups_final')">سويسري ← مجموعات ← نهائي</button>
</div>
</div>
<div class="wizard-nav"> <div class="wizard-nav">
<button type="button" class="btn btn-ghost wizard-prev" data-prev="1"> <button type="button" class="btn btn-ghost wizard-prev" data-prev="1">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment