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);
})();
...@@ -370,6 +370,642 @@ ...@@ -370,6 +370,642 @@
min-width: 100px; min-width: 100px;
} }
/* ===== Bracket Visualization ===== */
.bracket-container {
position: relative;
overflow-x: auto;
padding: var(--space-4);
}
.bracket-connectors {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 0;
}
.bracket-section {
position: relative;
z-index: 1;
margin-bottom: var(--space-6);
}
.bracket-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: var(--space-4);
color: var(--text-secondary);
}
.bracket-rounds {
display: flex;
gap: var(--space-6);
align-items: stretch;
}
.bracket-round {
display: flex;
flex-direction: column;
min-width: 200px;
}
.bracket-round-header {
text-align: center;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
padding-bottom: var(--space-3);
border-bottom: 2px solid var(--border-default);
margin-bottom: var(--space-4);
}
.bracket-round-matches {
display: flex;
flex-direction: column;
justify-content: space-around;
flex: 1;
gap: var(--space-3);
}
.bracket-match {
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-primary);
transition: border-color 0.2s;
}
.bracket-match.ready {
border-color: var(--color-primary);
}
.bracket-match.completed {
opacity: 0.85;
}
.bracket-match.bye {
opacity: 0.5;
}
.bracket-player {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: 0.8125rem;
border-bottom: 1px solid var(--border-subtle);
}
.bracket-player:last-of-type {
border-bottom: none;
}
.bracket-player.winner {
background: var(--color-success-bg, rgba(34, 197, 94, 0.1));
font-weight: 600;
}
.bracket-seed {
font-size: 0.6875rem;
color: var(--text-muted);
min-width: 16px;
}
.bracket-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bracket-score {
font-weight: 600;
font-variant-numeric: tabular-nums;
min-width: 20px;
text-align: center;
}
.bracket-result-btn {
display: block;
width: 100%;
padding: var(--space-1);
font-size: 0.6875rem;
text-align: center;
color: var(--color-primary);
background: var(--bg-elevated);
border: none;
cursor: pointer;
transition: background 0.2s;
}
.bracket-result-btn:hover {
background: var(--color-primary);
color: #fff;
}
.bracket-result-options {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.bracket-result-option {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s;
}
.bracket-result-option:hover {
background: var(--bg-elevated);
}
.bracket-result-option input[type="radio"] {
accent-color: var(--color-primary);
}
.modal-sm {
max-width: 400px;
}
/* ===== Phase Timeline ===== */
.phase-timeline {
display: flex;
flex-direction: column;
gap: 0;
padding: var(--space-4) 0;
}
.phase-step {
display: flex;
gap: var(--space-4);
}
.phase-step-indicator {
display: flex;
flex-direction: column;
align-items: center;
min-width: 32px;
}
.phase-step-circle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-tertiary);
color: var(--text-muted);
border: 2px solid var(--border-default);
}
.phase-step.in_progress .phase-step-circle {
background: var(--color-warning-bg, rgba(234, 179, 8, 0.15));
border-color: var(--color-warning, #eab308);
color: var(--color-warning, #eab308);
}
.phase-step.completed .phase-step-circle {
background: var(--color-success-bg, rgba(34, 197, 94, 0.15));
border-color: var(--color-success, #22c55e);
color: var(--color-success, #22c55e);
}
.phase-step-connector {
width: 2px;
flex: 1;
min-height: 24px;
background: var(--border-default);
margin: var(--space-1) 0;
}
.phase-step-connector.completed {
background: var(--color-success, #22c55e);
}
.phase-step-content {
padding-bottom: var(--space-4);
}
.phase-step-title {
font-size: 0.9375rem;
font-weight: 600;
margin-bottom: var(--space-1);
}
.phase-step-type {
font-size: 0.6875rem;
}
.phase-actions {
display: flex;
gap: var(--space-2);
}
/* ===== Phase Designer (Form) ===== */
.phase-designer {
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: var(--space-4);
background: var(--bg-elevated);
}
.phases-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.phase-card {
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-primary);
overflow: hidden;
}
.phase-card-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-subtle);
}
.phase-number {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 0.6875rem;
font-weight: 700;
flex-shrink: 0;
}
.phase-name-input {
flex: 1;
max-width: 180px;
}
.phase-type-select {
max-width: 140px;
}
.phase-card-body {
padding: var(--space-3);
}
.phase-connector {
display: flex;
justify-content: center;
padding: var(--space-1) 0;
color: var(--text-muted);
}
.phase-templates {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
/* Mode Selector */
.mode-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.mode-option {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 2px solid var(--border-default);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.mode-option:hover {
border-color: var(--color-primary);
}
.mode-option.active {
border-color: var(--color-primary);
background: var(--color-primary-bg, rgba(59, 130, 246, 0.05));
}
.mode-option input[type="radio"] {
accent-color: var(--color-primary);
}
.mode-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.mode-content strong {
font-size: 0.875rem;
}
.mode-content small {
font-size: 0.75rem;
color: var(--text-muted);
}
/* ===== Group Stage ===== */
.groups-container {
padding: var(--space-2) 0;
}
.group-card {
overflow: hidden;
}
.group-qualified td {
background: var(--color-success-bg, rgba(34, 197, 94, 0.08));
}
.group-match-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 0.8125rem;
}
.group-match-row:last-child {
border-bottom: none;
}
.group-match-row .vs {
color: var(--text-muted);
font-size: 0.6875rem;
}
/* ===== Arena Board ===== */
.arena-board {
padding: var(--space-2) 0;
}
.arena-header {
background: var(--bg-elevated);
}
.arena-timer-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.arena-status {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.875rem;
font-weight: 500;
}
.arena-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
}
.arena-status-dot.active {
background: var(--color-success, #22c55e);
animation: pulse 2s infinite;
}
.arena-status-dot.ended {
background: var(--color-danger, #ef4444);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.arena-timer {
font-size: 1.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.arena-timer.timer-ended {
color: var(--color-danger, #ef4444);
}
.arena-stats {
display: flex;
gap: var(--space-6);
}
.arena-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.arena-stat-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}
.arena-stat-label {
font-size: 0.6875rem;
color: var(--text-muted);
}
.arena-match-card {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
margin-bottom: var(--space-2);
}
.arena-match-player {
font-size: 0.875rem;
font-weight: 500;
flex: 1;
}
.arena-match-vs {
font-size: 0.6875rem;
color: var(--text-muted);
}
.arena-match-actions {
display: flex;
gap: var(--space-1);
}
/* ===== Export Panel ===== */
.export-buttons {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
padding: var(--space-4);
}
.export-btn {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
text-decoration: none;
transition: background 0.2s, border-color 0.2s;
}
.export-btn:hover {
background: var(--bg-elevated);
border-color: var(--color-primary);
}
.export-btn span {
display: flex;
flex-direction: column;
gap: 2px;
}
.export-btn strong {
font-size: 0.875rem;
}
.export-btn small {
font-size: 0.6875rem;
color: var(--text-muted);
}
/* ===== Import Tabs ===== */
.import-tabs {
display: flex;
gap: var(--space-1);
padding: var(--space-3);
border-bottom: 1px solid var(--border-default);
}
.import-tab {
padding: var(--space-2) var(--space-3);
font-size: 0.8125rem;
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
}
.import-tab.active {
background: var(--bg-elevated);
border-color: var(--border-default);
color: var(--text-primary);
font-weight: 500;
}
.import-tab-content {
padding: var(--space-4);
}
.csv-format-help {
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
}
.csv-format-help pre {
margin: 0;
white-space: pre-wrap;
font-family: var(--font-mono, monospace);
}
/* ===== Tiebreak Config ===== */
.tiebreak-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tiebreak-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-primary);
cursor: grab;
transition: box-shadow 0.2s;
}
.tiebreak-item:active,
.tiebreak-item.dragging {
cursor: grabbing;
box-shadow: var(--shadow-md);
opacity: 0.8;
}
.tiebreak-drag-handle {
color: var(--text-muted);
flex-shrink: 0;
}
.tiebreak-rank {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 0.625rem;
font-weight: 700;
flex-shrink: 0;
}
.tiebreak-name {
flex: 1;
font-size: 0.8125rem;
}
/* ===== Progress Bar ===== */
.progress-bar {
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
margin-bottom: var(--space-1);
}
.progress-fill {
height: 100%;
background: var(--color-primary);
border-radius: 3px;
transition: width 0.3s ease;
}
/* ===== Responsive ===== */ /* ===== Responsive ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.wizard-steps { .wizard-steps {
...@@ -397,4 +1033,34 @@ ...@@ -397,4 +1033,34 @@
.result-player:first-child { .result-player:first-child {
text-align: center; text-align: center;
} }
.bracket-rounds {
flex-direction: column;
gap: var(--space-4);
}
.bracket-round {
min-width: auto;
}
.bracket-connectors {
display: none;
}
.mode-selector {
grid-template-columns: 1fr;
}
.arena-stats {
flex-wrap: wrap;
gap: var(--space-3);
}
.export-buttons {
grid-template-columns: 1fr;
}
.phase-card-header {
flex-wrap: wrap;
}
} }
...@@ -232,6 +232,258 @@ ...@@ -232,6 +232,258 @@
} }
}); });
// ===== Load Round Pairings from Swiss API =====
window.loadRoundPairings = function(roundId, swissRoundId) {
var tournamentId = window.location.pathname.split('/')[2];
var container = document.getElementById('roundPairings_' + roundId);
if (!container) return;
container.innerHTML = '<div class="text-center p-4"><div class="spinner"></div> جاري التحميل...</div>';
fetch('/tournaments/' + tournamentId + '/rounds/' + roundId + '/pairings')
.then(function(r) { return r.json(); })
.then(function(data) {
var pairings = data.pairings || [];
if (!pairings.length) {
container.innerHTML = '<p class="text-muted p-4">لا توجد مواجهات</p>';
return;
}
var html = '<table class="data-table"><thead><tr><th>#</th><th>الأبيض</th><th>النتيجة</th><th>الأسود</th></tr></thead><tbody>';
pairings.forEach(function(p, i) {
var whiteN = (p.playerA && p.playerA.name) || (p.white && p.white.name) || '-';
var blackN = (p.playerB && p.playerB.name) || (p.black && p.black.name) || '-';
var result = p.result || '-';
html += '<tr><td>' + (i+1) + '</td><td>' + escapeHtml(whiteN) + '</td><td class="text-center"><span class="badge badge-default">' + result + '</span></td><td>' + escapeHtml(blackN) + '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function() {
container.innerHTML = '<p class="text-danger p-4">فشل في تحميل المواجهات</p>';
});
};
// Enhanced openResultsForm with Swiss API pairings fetch
window.openResultsForm = function(roundId, swissRoundIdOrPairings) {
var modal = document.getElementById('resultsModal');
var roundIdInput = document.getElementById('resultsRoundId');
var body = document.getElementById('resultsBody');
var form = document.getElementById('resultsForm');
if (!modal || !roundIdInput || !body) return;
roundIdInput.value = roundId;
var tournamentId = window.location.pathname.split('/')[2];
form.action = '/tournaments/' + tournamentId + '/rounds/' + roundId + '/results';
// If swissRoundIdOrPairings is a string (swiss round ID), fetch from API
if (typeof swissRoundIdOrPairings === 'string' && swissRoundIdOrPairings.length > 10) {
body.innerHTML = '<div class="text-center p-4"><div class="spinner"></div> جاري تحميل المواجهات...</div>';
modal.style.display = 'flex';
fetch('/tournaments/' + tournamentId + '/rounds/' + roundId + '/pairings')
.then(function(r) { return r.json(); })
.then(function(data) {
renderResultsForm(body, data.pairings || [], roundId);
})
.catch(function() {
body.innerHTML = '<p class="text-danger">فشل في تحميل المواجهات</p>';
});
return;
}
// Legacy: pairings passed directly
var pairings = typeof swissRoundIdOrPairings === 'object' ? swissRoundIdOrPairings : [];
renderResultsForm(body, pairings, roundId);
modal.style.display = 'flex';
};
function renderResultsForm(body, pairings, roundId) {
body.innerHTML = '';
if (!pairings || !pairings.length) {
body.innerHTML = '<p class="text-secondary p-4">لا توجد مواجهات في هذه الجولة</p>';
return;
}
pairings.forEach(function(pairing, idx) {
var whiteName = (pairing.playerA && pairing.playerA.name) || (pairing.white && pairing.white.name) || 'لاعب ' + (idx * 2 + 1);
var blackName = (pairing.playerB && pairing.playerB.name) || (pairing.black && pairing.black.name) || 'لاعب ' + (idx * 2 + 2);
var pairingId = pairing.id || idx;
var row = document.createElement('div');
row.className = 'result-entry';
row.innerHTML =
'<span class="result-player">' + escapeHtml(whiteName) + '</span>' +
'<select name="results[' + pairingId + ']" class="form-input result-select">' +
' <option value="">-</option>' +
' <option value="1-0">1 - 0</option>' +
' <option value="0.5-0.5">½ - ½</option>' +
' <option value="0-1">0 - 1</option>' +
' <option value="1-0F">1-0 (غياب)</option>' +
' <option value="0-1F">0-1 (غياب)</option>' +
'</select>' +
'<span class="result-player">' + escapeHtml(blackName) + '</span>';
body.appendChild(row);
});
}
// ===== Manual Pairing Toggle =====
window.toggleManualPairing = function() {
var form = document.getElementById('manualPairingForm');
if (form) {
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
};
// ===== Phase Designer =====
window.togglePhaseDesigner = function() {
var designer = document.getElementById('phaseDesigner');
var modeRadios = document.querySelectorAll('input[name="tournament_mode"]');
var selectedMode = 'single';
modeRadios.forEach(function(r) { if (r.checked) selectedMode = r.value; });
if (designer) {
designer.style.display = selectedMode === 'multi_phase' ? 'block' : 'none';
}
// Update mode option styling
document.querySelectorAll('.mode-option').forEach(function(opt) {
opt.classList.remove('active');
var radio = opt.querySelector('input[type="radio"]');
if (radio && radio.checked) opt.classList.add('active');
});
};
var phaseCount = 0;
var phasesData = [];
window.addPhase = function(preset) {
phaseCount++;
var phaseData = preset || {
name: 'المرحلة ' + phaseCount,
name_ar: 'المرحلة ' + phaseCount,
type: 'swiss',
settings: { rounds: 5 },
advancement: { count: 8, method: 'standings' }
};
phasesData.push(phaseData);
renderPhases();
updatePhasesInput();
};
window.removePhase = function(idx) {
phasesData.splice(idx, 1);
phaseCount = phasesData.length;
renderPhases();
updatePhasesInput();
};
function renderPhases() {
var list = document.getElementById('phasesList');
if (!list) return;
var typeLabels = {
'swiss': 'سويسري',
'round_robin': 'دوري كامل',
'single_elimination': 'إقصاء مباشر',
'double_elimination': 'إقصاء مزدوج',
'arena': 'أرينا',
'group_stage': 'مجموعات'
};
list.innerHTML = phasesData.map(function(phase, i) {
return '<div class="phase-card">' +
'<div class="phase-card-header">' +
'<span class="phase-number">' + (i + 1) + '</span>' +
'<input type="text" class="form-input form-input-sm phase-name-input" value="' + escapeHtml(phase.name_ar || phase.name) + '" onchange="updatePhaseName(' + i + ', this.value)">' +
'<select class="form-input form-input-sm phase-type-select" onchange="updatePhaseType(' + i + ', this.value)">' +
Object.keys(typeLabels).map(function(key) {
return '<option value="' + key + '"' + (phase.type === key ? ' selected' : '') + '>' + typeLabels[key] + '</option>';
}).join('') +
'</select>' +
'<button type="button" class="btn btn-icon btn-xs btn-ghost text-danger" onclick="removePhase(' + i + ')"><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>' +
'<div class="phase-card-body">' +
'<div class="grid grid-3 gap-2">' +
(phase.type === 'swiss' || phase.type === 'round_robin' ?
'<div class="form-group"><label class="text-xs">جولات</label><input type="number" class="form-input form-input-sm" value="' + (phase.settings.rounds || 5) + '" onchange="updatePhaseSetting(' + i + ', \'rounds\', this.value)" min="1"></div>' : '') +
(phase.type === 'group_stage' ?
'<div class="form-group"><label class="text-xs">مجموعات</label><input type="number" class="form-input form-input-sm" value="' + (phase.settings.groups || 4) + '" onchange="updatePhaseSetting(' + i + ', \'groups\', this.value)" min="2"></div>' +
'<div class="form-group"><label class="text-xs">يتأهل/مجموعة</label><input type="number" class="form-input form-input-sm" value="' + (phase.settings.advance_per_group || 2) + '" onchange="updatePhaseSetting(' + i + ', \'advance_per_group\', this.value)" min="1"></div>' : '') +
(phase.type === 'arena' ?
'<div class="form-group"><label class="text-xs">المدة (دقيقة)</label><input type="number" class="form-input form-input-sm" value="' + (phase.settings.duration_minutes || 60) + '" onchange="updatePhaseSetting(' + i + ', \'duration_minutes\', this.value)" min="5"></div>' : '') +
(i < phasesData.length - 1 ?
'<div class="form-group"><label class="text-xs">يتأهل</label><input type="number" class="form-input form-input-sm" value="' + (phase.advancement.count || 8) + '" onchange="updatePhaseAdvancement(' + i + ', this.value)" min="1"></div>' : '') +
'</div>' +
'</div>' +
(i < phasesData.length - 1 ? '<div class="phase-connector"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg></div>' : '') +
'</div>';
}).join('');
}
window.updatePhaseName = function(idx, value) {
phasesData[idx].name = value;
phasesData[idx].name_ar = value;
updatePhasesInput();
};
window.updatePhaseType = function(idx, value) {
phasesData[idx].type = value;
renderPhases();
updatePhasesInput();
};
window.updatePhaseSetting = function(idx, key, value) {
phasesData[idx].settings[key] = parseInt(value) || value;
updatePhasesInput();
};
window.updatePhaseAdvancement = function(idx, value) {
phasesData[idx].advancement.count = parseInt(value) || 8;
updatePhasesInput();
};
function updatePhasesInput() {
var input = document.getElementById('phasesInput');
if (input) input.value = JSON.stringify(phasesData);
}
// Phase Templates
window.loadTemplate = function(template) {
phasesData = [];
phaseCount = 0;
switch (template) {
case 'swiss_bracket':
addPhase({ name: 'تصفيات سويسري', name_ar: 'تصفيات سويسري', type: 'swiss', settings: { rounds: 7 }, advancement: { count: 8, method: 'standings' } });
addPhase({ name: 'الإقصاء النهائي', name_ar: 'الإقصاء النهائي', type: 'single_elimination', settings: {}, advancement: { count: 1, method: 'standings' } });
break;
case 'groups_ko':
addPhase({ name: 'مرحلة المجموعات', name_ar: 'مرحلة المجموعات', type: 'group_stage', settings: { groups: 4, advance_per_group: 2 }, advancement: { count: 8, method: 'standings' } });
addPhase({ name: 'الأدوار الإقصائية', name_ar: 'الأدوار الإقصائية', type: 'single_elimination', settings: {}, advancement: { count: 1, method: 'standings' } });
break;
case 'swiss_groups_final':
addPhase({ name: 'التصفيات', name_ar: 'التصفيات', type: 'swiss', settings: { rounds: 5 }, advancement: { count: 16, method: 'standings' } });
addPhase({ name: 'المجموعات', name_ar: 'المجموعات', type: 'group_stage', settings: { groups: 4, advance_per_group: 2 }, advancement: { count: 8, method: 'standings' } });
addPhase({ name: 'النهائيات', name_ar: 'النهائيات', type: 'single_elimination', settings: {}, advancement: { count: 1, method: 'standings' } });
break;
}
};
// Init: load existing phases if editing
(function() {
var input = document.getElementById('phasesInput');
if (input && input.value && input.value !== '[]') {
try {
phasesData = JSON.parse(input.value);
phaseCount = phasesData.length;
renderPhases();
} catch(e) {}
}
})();
// ===== Utility ===== // ===== Utility =====
function escapeHtml(str) { function escapeHtml(str) {
var div = document.createElement('div'); var div = document.createElement('div');
......
<?php <?php
require_once __DIR__ . '/services/SwissApiService.php';
require_once __DIR__ . '/services/BracketEngine.php';
require_once __DIR__ . '/services/PhaseManager.php';
require_once __DIR__ . '/services/ArenaEngine.php';
require_once __DIR__ . '/services/ExportService.php';
class TournamentsController class TournamentsController
{ {
private Database $db; private Database $db;
...@@ -9,20 +15,27 @@ class TournamentsController ...@@ -9,20 +15,27 @@ class TournamentsController
$this->db = Database::getInstance(); $this->db = Database::getInstance();
} }
// ==========================================
// CORE CRUD
// ==========================================
public function list(array $params, string $method): void public function list(array $params, string $method): void
{ {
$status = $_GET['status'] ?? ''; $status = $_GET['status'] ?? '';
$search = $_GET['search'] ?? ''; $search = $_GET['search'] ?? '';
$mode = $_GET['mode'] ?? '';
$queryParams = ['select' => '*', 'order' => 'created_at.desc']; $queryParams = ['select' => '*', 'order' => 'created_at.desc'];
if ($status) { if ($status) {
$queryParams['status'] = "eq.{$status}"; $queryParams['status'] = "eq.{$status}";
} }
if ($search) { if ($search) {
$queryParams['name'] = "ilike.*{$search}*"; $queryParams['name'] = "ilike.*{$search}*";
} }
if ($mode) {
$queryParams['tournament_mode'] = "eq.{$mode}";
}
$countParams = $queryParams; $countParams = $queryParams;
unset($countParams['select'], $countParams['order']); unset($countParams['select'], $countParams['order']);
...@@ -38,7 +51,7 @@ class TournamentsController ...@@ -38,7 +51,7 @@ class TournamentsController
$moduleCSS = 'tournaments'; $moduleCSS = 'tournaments';
$moduleJS = 'tournaments'; $moduleJS = 'tournaments';
View::render('tournaments/list', compact('tournaments', 'pagination', 'search', 'status', 'pageTitle', 'moduleCSS', 'moduleJS')); View::render('tournaments/list', compact('tournaments', 'pagination', 'search', 'status', 'mode', 'pageTitle', 'moduleCSS', 'moduleJS'));
} }
public function show(array $params, string $method): void public function show(array $params, string $method): void
...@@ -52,40 +65,66 @@ class TournamentsController ...@@ -52,40 +65,66 @@ class TournamentsController
return; return;
} }
// Fetch rounds from local DB
$rounds = $this->db->select('el3ab_tournament_rounds', [ $rounds = $this->db->select('el3ab_tournament_rounds', [
'tournament_id' => "eq.{$id}", 'tournament_id' => "eq.{$id}",
'order' => 'round_number.asc', 'order' => 'round_number.asc',
]); ]);
// Fetch standings from Swiss API if tournament is in progress or completed
$standings = []; $standings = [];
if (in_array($tournament['status'], ['in_progress', 'completed']) && !empty($tournament['swiss_api_tournament_id'])) { if (in_array($tournament['status'], ['in_progress', 'completed']) && !empty($tournament['swiss_api_tournament_id'])) {
$response = ApiProxy::swiss('GET', '/tournaments/' . $tournament['swiss_api_tournament_id'] . '/standings'); $response = SwissApiService::getStandings($tournament['swiss_api_tournament_id']);
if ($response['status'] === 200 && is_array($response['body'])) { if (SwissApiService::isSuccess($response)) {
$standings = $response['body']; $standings = $response['body'] ?? [];
} }
} }
// Fetch registered players
$players = $this->db->select('tournament_registrations', [ $players = $this->db->select('tournament_registrations', [
'tournament_id' => "eq.{$id}", 'tournament_id' => "eq.{$id}",
'order' => 'registered_at.asc', 'order' => 'registered_at.asc',
]); ]);
// Phase data for multi-phase tournaments
$phases = [];
$brackets = [];
$currentPhaseDetails = null;
if (($tournament['tournament_mode'] ?? 'single') === 'multi_phase') {
$phaseManager = new PhaseManager();
$phaseStatus = $phaseManager->getPhaseStatus($id);
$phases = $phaseStatus['phases'];
$brackets = $this->db->select('tournament_brackets', [
'tournament_id' => "eq.{$id}",
'order' => 'created_at.asc',
]);
$activePhase = null;
foreach ($phases as $p) {
if ($p['status'] === 'in_progress') {
$activePhase = $p;
break;
}
}
if ($activePhase) {
$currentPhaseDetails = $phaseManager->getPhaseDetails($activePhase['id']);
}
}
$tab = $_GET['tab'] ?? 'info'; $tab = $_GET['tab'] ?? 'info';
$pageTitle = $tournament['name']; $pageTitle = $tournament['name'];
$moduleCSS = 'tournaments'; $moduleCSS = 'tournaments';
$moduleJS = 'tournaments'; $moduleJS = 'tournaments';
View::render('tournaments/show', compact('tournament', 'rounds', 'standings', 'players', 'tab', 'pageTitle', 'moduleCSS', 'moduleJS')); View::render('tournaments/show', compact(
'tournament', 'rounds', 'standings', 'players', 'phases', 'brackets',
'currentPhaseDetails', 'tab', 'pageTitle', 'moduleCSS', 'moduleJS'
));
} }
public function create(array $params, string $method): void public function create(array $params, string $method): void
{ {
$tournament = []; $tournament = [];
// Fetch games and organizations for the form
$games = $this->db->select('game_plugins', [ $games = $this->db->select('game_plugins', [
'select' => 'game_key,name_ar', 'select' => 'game_key,name_ar',
'is_enabled' => 'eq.true', 'is_enabled' => 'eq.true',
...@@ -120,7 +159,7 @@ class TournamentsController ...@@ -120,7 +159,7 @@ class TournamentsController
return; return;
} }
// Create organization in Swiss API if needed $tournamentMode = $_POST['tournament_mode'] ?? 'single';
$orgId = trim($_POST['organization_id'] ?? ''); $orgId = trim($_POST['organization_id'] ?? '');
$swissOrgId = null; $swissOrgId = null;
...@@ -129,10 +168,11 @@ class TournamentsController ...@@ -129,10 +168,11 @@ class TournamentsController
$swissOrgId = $org['swiss_org_id'] ?? null; $swissOrgId = $org['swiss_org_id'] ?? null;
if (!$swissOrgId) { if (!$swissOrgId) {
$orgResponse = ApiProxy::swiss('POST', '/organizations', [ $orgResponse = SwissApiService::createOrganization(
'name' => $org['name'], $org['name'],
]); $org['contact_email'] ?? SWISS_API_EMAIL
if ($orgResponse['status'] === 201 || $orgResponse['status'] === 200) { );
if (SwissApiService::isSuccess($orgResponse)) {
$swissOrgId = $orgResponse['body']['id'] ?? null; $swissOrgId = $orgResponse['body']['id'] ?? null;
$this->db->update('el3ab_organizations', ['id' => "eq.{$orgId}"], [ $this->db->update('el3ab_organizations', ['id' => "eq.{$orgId}"], [
'swiss_org_id' => $swissOrgId, 'swiss_org_id' => $swissOrgId,
...@@ -141,40 +181,40 @@ class TournamentsController ...@@ -141,40 +181,40 @@ class TournamentsController
} }
} }
// Create event in Swiss API
$swissEventId = null; $swissEventId = null;
$swissTournamentId = null; $swissTournamentId = null;
if ($swissOrgId) { if ($swissOrgId) {
$eventResponse = ApiProxy::swiss('POST', '/organizations/' . $swissOrgId . '/events', [ $eventResponse = SwissApiService::createEvent(
'name' => trim($_POST['name']), $swissOrgId,
'start_date' => $_POST['start_date'], trim($_POST['name']),
]); $_POST['start_date'],
if ($eventResponse['status'] === 201 || $eventResponse['status'] === 200) { $_POST['end_date'] ?? null
);
if (SwissApiService::isSuccess($eventResponse)) {
$swissEventId = $eventResponse['body']['id'] ?? null; $swissEventId = $eventResponse['body']['id'] ?? null;
} }
// Create tournament in Swiss API if ($swissEventId && $tournamentMode === 'single') {
if ($swissEventId) { $tournamentResponse = SwissApiService::createTournament($swissEventId, [
$tournamentResponse = ApiProxy::swiss('POST', '/events/' . $swissEventId . '/tournaments', [
'name' => trim($_POST['name']), 'name' => trim($_POST['name']),
'format' => $_POST['format'], 'tournamentType' => $_POST['format'] ?? 'swiss',
'rounds' => (int)($_POST['rounds_count'] ?? 5), 'roundsNumber' => (int)($_POST['rounds_count'] ?? 5),
'time_control' => trim($_POST['time_control'] ?? ''), 'maxPlayers' => (int)($_POST['max_players'] ?? 200),
]); ]);
if ($tournamentResponse['status'] === 201 || $tournamentResponse['status'] === 200) { if (SwissApiService::isSuccess($tournamentResponse)) {
$swissTournamentId = $tournamentResponse['body']['id'] ?? null; $swissTournamentId = $tournamentResponse['body']['id'] ?? null;
} }
} }
} }
// Save tournament locally
$data = [ $data = [
'name' => trim($_POST['name']), 'name' => trim($_POST['name']),
'description' => trim($_POST['description'] ?? ''), 'description' => trim($_POST['description'] ?? ''),
'game_key' => $_POST['game_key'], 'game_key' => $_POST['game_key'],
'organization_id' => $orgId ?: null, 'organization_id' => $orgId ?: null,
'format' => $_POST['format'], 'format' => $_POST['format'],
'tournament_mode' => $tournamentMode,
'rounds_count' => (int)($_POST['rounds_count'] ?? 5), 'rounds_count' => (int)($_POST['rounds_count'] ?? 5),
'time_control' => trim($_POST['time_control'] ?? ''), 'time_control' => trim($_POST['time_control'] ?? ''),
'max_players' => (int)$_POST['max_players'], 'max_players' => (int)$_POST['max_players'],
...@@ -190,7 +230,18 @@ class TournamentsController ...@@ -190,7 +230,18 @@ class TournamentsController
'created_by' => $_SESSION['user']['username'] ?? 'system', 'created_by' => $_SESSION['user']['username'] ?? 'system',
]; ];
$this->db->insert('el3ab_tournaments', $data); $result = $this->db->insert('el3ab_tournaments', $data);
$tournamentId = $result['id'] ?? null;
// Initialize phases for multi-phase tournaments
if ($tournamentMode === 'multi_phase' && !empty($_POST['phases']) && $tournamentId) {
$phaseConfigs = json_decode($_POST['phases'], true) ?? [];
if (!empty($phaseConfigs)) {
$phaseManager = new PhaseManager();
$phaseManager->initializePhases($tournamentId, $phaseConfigs);
}
}
AuditLog::log('create', 'tournament', $data['name'], null, $data); AuditLog::log('create', 'tournament', $data['name'], null, $data);
Response::success('تم إنشاء البطولة بنجاح', '/tournaments'); Response::success('تم إنشاء البطولة بنجاح', '/tournaments');
} }
...@@ -251,6 +302,7 @@ class TournamentsController ...@@ -251,6 +302,7 @@ class TournamentsController
'game_key' => $_POST['game_key'], 'game_key' => $_POST['game_key'],
'organization_id' => $_POST['organization_id'] ?: null, 'organization_id' => $_POST['organization_id'] ?: null,
'format' => $_POST['format'], 'format' => $_POST['format'],
'tournament_mode' => $_POST['tournament_mode'] ?? 'single',
'rounds_count' => (int)($_POST['rounds_count'] ?? 5), 'rounds_count' => (int)($_POST['rounds_count'] ?? 5),
'time_control' => trim($_POST['time_control'] ?? ''), 'time_control' => trim($_POST['time_control'] ?? ''),
'max_players' => (int)$_POST['max_players'], 'max_players' => (int)$_POST['max_players'],
...@@ -291,10 +343,9 @@ class TournamentsController ...@@ -291,10 +343,9 @@ class TournamentsController
foreach ($players as $player) { foreach ($players as $player) {
$profile = $this->db->selectOne('profiles', ['id' => "eq.{$player['player_id']}"]); $profile = $this->db->selectOne('profiles', ['id' => "eq.{$player['player_id']}"]);
ApiProxy::swiss('POST', '/tournaments/' . $tournament['swiss_api_tournament_id'] . '/players', [ SwissApiService::registerPlayer($tournament['swiss_api_tournament_id'], [
'id' => $player['player_id'],
'name' => $profile['display_name'] ?? $profile['username'] ?? $player['player_id'], 'name' => $profile['display_name'] ?? $profile['username'] ?? $player['player_id'],
'rating' => $profile['elo_blitz'] ?? 1500, 'fideRatingStandard' => $profile['elo_blitz'] ?? 1500,
]); ]);
} }
} }
...@@ -305,6 +356,18 @@ class TournamentsController ...@@ -305,6 +356,18 @@ class TournamentsController
'updated_at' => date('c'), 'updated_at' => date('c'),
]); ]);
// For multi-phase, start first phase
if (($tournament['tournament_mode'] ?? 'single') === 'multi_phase') {
$firstPhase = $this->db->selectOne('tournament_phases', [
'tournament_id' => "eq.{$id}",
'phase_number' => 'eq.1',
]);
if ($firstPhase) {
$phaseManager = new PhaseManager();
$phaseManager->startPhase($firstPhase['id']);
}
}
AuditLog::log('start', 'tournament', $id, ['status' => $tournament['status']], ['status' => 'in_progress']); AuditLog::log('start', 'tournament', $id, ['status' => $tournament['status']], ['status' => 'in_progress']);
Response::success('تم بدء البطولة', '/tournaments/' . $id); Response::success('تم بدء البطولة', '/tournaments/' . $id);
} }
...@@ -360,18 +423,17 @@ class TournamentsController ...@@ -360,18 +423,17 @@ class TournamentsController
Response::success('تم إلغاء البطولة', '/tournaments/' . $id); Response::success('تم إلغاء البطولة', '/tournaments/' . $id);
} }
// ==========================================
// SWISS ROUNDS & RESULTS
// ==========================================
public function generateRound(array $params, string $method): void public function generateRound(array $params, string $method): void
{ {
Auth::requireCsrf(); Auth::requireCsrf();
$id = $params['id']; $id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]); $tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament) { if (!$tournament || $tournament['status'] !== 'in_progress') {
Response::error('البطولة غير موجودة', '/tournaments');
return;
}
if ($tournament['status'] !== 'in_progress') {
Response::error('البطولة ليست جارية', '/tournaments/' . $id); Response::error('البطولة ليست جارية', '/tournaments/' . $id);
return; return;
} }
...@@ -381,18 +443,16 @@ class TournamentsController ...@@ -381,18 +443,16 @@ class TournamentsController
return; return;
} }
// Call Swiss API to generate round $response = SwissApiService::generateRound($tournament['swiss_api_tournament_id']);
$response = ApiProxy::swiss('POST', '/tournaments/' . $tournament['swiss_api_tournament_id'] . '/rounds/generate');
if ($response['status'] !== 200 && $response['status'] !== 201) { if (!SwissApiService::isSuccess($response)) {
$errorMsg = $response['body']['message'] ?? 'فشل في إنشاء الجولة'; Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=rounds');
Response::error($errorMsg, '/tournaments/' . $id . '?tab=rounds');
return; return;
} }
$roundData = $response['body']; $roundData = $response['body']['round'] ?? $response['body'];
$pairingsCount = $response['body']['pairingsCount'] ?? 0;
// Save round locally
$currentRounds = $this->db->count('el3ab_tournament_rounds', ['tournament_id' => "eq.{$id}"]); $currentRounds = $this->db->count('el3ab_tournament_rounds', ['tournament_id' => "eq.{$id}"]);
$roundNumber = $currentRounds + 1; $roundNumber = $currentRounds + 1;
...@@ -400,12 +460,11 @@ class TournamentsController ...@@ -400,12 +460,11 @@ class TournamentsController
'tournament_id' => $id, 'tournament_id' => $id,
'round_number' => $roundNumber, 'round_number' => $roundNumber,
'swiss_round_id' => $roundData['id'] ?? null, 'swiss_round_id' => $roundData['id'] ?? null,
'pairings' => json_encode($roundData['pairings'] ?? []), 'pairings' => json_encode(['count' => $pairingsCount, 'bye' => $response['body']['bye'] ?? null]),
'status' => 'in_progress', 'status' => 'in_progress',
'created_at' => date('c'), 'created_at' => date('c'),
]); ]);
// Update current round in tournament
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [ $this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'current_round' => $roundNumber, 'current_round' => $roundNumber,
'updated_at' => date('c'), 'updated_at' => date('c'),
...@@ -415,11 +474,108 @@ class TournamentsController ...@@ -415,11 +474,108 @@ class TournamentsController
Response::success("تم إنشاء الجولة {$roundNumber}", '/tournaments/' . $id . '?tab=rounds'); Response::success("تم إنشاء الجولة {$roundNumber}", '/tournaments/' . $id . '?tab=rounds');
} }
public function pairings(array $params, string $method): void
{
$id = $params['id'];
$roundId = $params['roundId'] ?? $params['round_id'] ?? '';
$round = $this->db->selectOne('el3ab_tournament_rounds', [
'id' => "eq.{$roundId}",
'tournament_id' => "eq.{$id}",
]);
if (!$round || empty($round['swiss_round_id'])) {
Response::json(['error' => 'Round not found'], 404);
return;
}
$response = SwissApiService::getPairings($round['swiss_round_id']);
if (SwissApiService::isSuccess($response)) {
Response::json(['pairings' => $response['body'], 'round' => $round]);
} else {
Response::json(['error' => SwissApiService::getError($response)], 500);
}
}
public function manualPairing(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$roundId = $params['roundId'] ?? $params['round_id'] ?? '';
$round = $this->db->selectOne('el3ab_tournament_rounds', [
'id' => "eq.{$roundId}",
'tournament_id' => "eq.{$id}",
]);
if (!$round || empty($round['swiss_round_id'])) {
Response::error('الجولة غير موجودة', '/tournaments/' . $id . '?tab=rounds');
return;
}
$playerAId = $_POST['player_a_id'] ?? '';
$playerBId = $_POST['player_b_id'] ?? '';
if (!$playerAId || !$playerBId) {
Response::error('يجب اختيار لاعبين', '/tournaments/' . $id . '?tab=rounds');
return;
}
$response = SwissApiService::createManualPairing($round['swiss_round_id'], $playerAId, $playerBId);
if (SwissApiService::isSuccess($response)) {
Response::success('تم إنشاء المواجهة', '/tournaments/' . $id . '?tab=rounds');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=rounds');
}
}
public function unpairRound(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$roundId = $params['roundId'] ?? $params['round_id'] ?? '';
$round = $this->db->selectOne('el3ab_tournament_rounds', [
'id' => "eq.{$roundId}",
'tournament_id' => "eq.{$id}",
]);
if (!$round || empty($round['swiss_round_id'])) {
Response::error('الجولة غير موجودة', '/tournaments/' . $id . '?tab=rounds');
return;
}
$response = SwissApiService::unpairRound($round['swiss_round_id']);
if (SwissApiService::isSuccess($response)) {
$this->db->update('el3ab_tournament_rounds', ['id' => "eq.{$roundId}"], [
'pairings' => json_encode([]),
'status' => 'unpaired',
]);
Response::success('تم إلغاء المواجهات', '/tournaments/' . $id . '?tab=rounds');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=rounds');
}
}
public function deletePairing(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$pairingId = $params['pairingId'] ?? '';
$response = SwissApiService::deletePairing($pairingId);
if (SwissApiService::isSuccess($response)) {
Response::success('تم حذف المواجهة', '/tournaments/' . $id . '?tab=rounds');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=rounds');
}
}
public function submitResults(array $params, string $method): void public function submitResults(array $params, string $method): void
{ {
Auth::requireCsrf(); Auth::requireCsrf();
$id = $params['id']; $id = $params['id'];
$roundId = $params['round_id'] ?? $_POST['round_id'] ?? null; $roundId = $params['roundId'] ?? $params['round_id'] ?? $_POST['round_id'] ?? null;
if (!$roundId) { if (!$roundId) {
Response::error('لم يتم تحديد الجولة', '/tournaments/' . $id . '?tab=rounds'); Response::error('لم يتم تحديد الجولة', '/tournaments/' . $id . '?tab=rounds');
...@@ -442,13 +598,12 @@ class TournamentsController ...@@ -442,13 +598,12 @@ class TournamentsController
return; return;
} }
// Collect results from POST data
$results = []; $results = [];
if (!empty($_POST['results']) && is_array($_POST['results'])) { if (!empty($_POST['results']) && is_array($_POST['results'])) {
foreach ($_POST['results'] as $pairingIndex => $result) { foreach ($_POST['results'] as $pairingId => $result) {
$results[] = [ $results[] = [
'pairing_index' => (int)$pairingIndex, 'pairingId' => $pairingId,
'result' => $result, // '1-0', '0-1', '0.5-0.5' 'result' => $result,
]; ];
} }
} }
...@@ -458,20 +613,14 @@ class TournamentsController ...@@ -458,20 +613,14 @@ class TournamentsController
return; return;
} }
// Submit results to Swiss API
if (!empty($round['swiss_round_id'])) { if (!empty($round['swiss_round_id'])) {
$response = ApiProxy::swiss('PATCH', '/rounds/' . $round['swiss_round_id'] . '/pairings', [ $response = SwissApiService::submitBatchResults($round['swiss_round_id'], $results);
'results' => $results, if (!SwissApiService::isSuccess($response)) {
]); Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=rounds');
if ($response['status'] !== 200) {
$errorMsg = $response['body']['message'] ?? 'فشل في إرسال النتائج';
Response::error($errorMsg, '/tournaments/' . $id . '?tab=rounds');
return; return;
} }
} }
// Update round locally
$this->db->update('el3ab_tournament_rounds', ['id' => "eq.{$roundId}"], [ $this->db->update('el3ab_tournament_rounds', ['id' => "eq.{$roundId}"], [
'results' => json_encode($results), 'results' => json_encode($results),
'status' => 'completed', 'status' => 'completed',
...@@ -501,9 +650,12 @@ class TournamentsController ...@@ -501,9 +650,12 @@ class TournamentsController
return; return;
} }
$response = ApiProxy::swiss('GET', '/tournaments/' . $tournament['swiss_api_tournament_id'] . '/standings'); $round = isset($_GET['round']) ? (int)$_GET['round'] : null;
$categoryId = $_GET['category_id'] ?? null;
if ($response['status'] === 200) { $response = SwissApiService::getStandings($tournament['swiss_api_tournament_id'], $round, $categoryId);
if (SwissApiService::isSuccess($response)) {
Response::json([ Response::json([
'standings' => $response['body'], 'standings' => $response['body'],
'tournament_id' => $id, 'tournament_id' => $id,
...@@ -513,4 +665,482 @@ class TournamentsController ...@@ -513,4 +665,482 @@ class TournamentsController
Response::json(['error' => 'فشل في جلب الترتيب', 'details' => $response['body']], 500); Response::json(['error' => 'فشل في جلب الترتيب', 'details' => $response['body']], 500);
} }
} }
public function recalculateStandings(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id . '?tab=standings');
return;
}
$response = SwissApiService::recalculateStandings($tournament['swiss_api_tournament_id']);
if (SwissApiService::isSuccess($response)) {
Response::success('تم إعادة حساب الترتيب', '/tournaments/' . $id . '?tab=standings');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=standings');
}
}
// ==========================================
// CATEGORIES
// ==========================================
public function categories(array $params, string $method): void
{
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::json(['error' => 'Tournament not linked to Swiss API'], 400);
return;
}
$response = SwissApiService::listCategories($tournament['swiss_api_tournament_id']);
if (SwissApiService::isSuccess($response)) {
Response::json(['categories' => $response['body']]);
} else {
Response::json(['error' => SwissApiService::getError($response)], 500);
}
}
public function storeCategory(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id . '?tab=categories');
return;
}
$data = [
'name' => trim($_POST['name'] ?? ''),
];
if (!empty($_POST['min_rating'])) $data['minRating'] = (int)$_POST['min_rating'];
if (!empty($_POST['max_rating'])) $data['maxRating'] = (int)$_POST['max_rating'];
if (!empty($_POST['min_age'])) $data['minAge'] = (int)$_POST['min_age'];
if (!empty($_POST['max_age'])) $data['maxAge'] = (int)$_POST['max_age'];
if (!empty($_POST['gender'])) $data['gender'] = $_POST['gender'];
$response = SwissApiService::createCategory($tournament['swiss_api_tournament_id'], $data);
if (SwissApiService::isSuccess($response)) {
Response::success('تم إنشاء الفئة', '/tournaments/' . $id . '?tab=categories');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=categories');
}
}
public function deleteCategory(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$catId = $params['catId'] ?? '';
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id . '?tab=categories');
return;
}
$response = SwissApiService::deleteCategory($tournament['swiss_api_tournament_id'], $catId);
if (SwissApiService::isSuccess($response)) {
Response::success('تم حذف الفئة', '/tournaments/' . $id . '?tab=categories');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=categories');
}
}
// ==========================================
// PLAYERS
// ==========================================
public function importPlayers(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id . '?tab=players');
return;
}
$playersData = [];
// Handle CSV upload
if (!empty($_FILES['csv_file']['tmp_name'])) {
$csv = file_get_contents($_FILES['csv_file']['tmp_name']);
$lines = array_filter(explode("\n", $csv));
$header = str_getcsv(array_shift($lines));
foreach ($lines as $line) {
$row = str_getcsv($line);
if (count($row) < count($header)) continue;
$player = array_combine($header, $row);
$playersData[] = [
'name' => trim($player['name'] ?? $player['Name'] ?? ''),
'fideRatingStandard' => (int)($player['rating'] ?? $player['Rating'] ?? $player['fideRatingStandard'] ?? 1500),
'fideId' => trim($player['fide_id'] ?? $player['FIDE ID'] ?? ''),
];
}
}
// Handle paste data
if (!empty($_POST['players_text'])) {
$lines = array_filter(explode("\n", $_POST['players_text']));
foreach ($lines as $line) {
$parts = preg_split('/[\t,;]+/', trim($line));
if (empty($parts[0])) continue;
$playersData[] = [
'name' => trim($parts[0]),
'fideRatingStandard' => (int)($parts[1] ?? 1500),
'fideId' => trim($parts[2] ?? ''),
];
}
}
if (empty($playersData)) {
Response::error('لم يتم العثور على بيانات لاعبين', '/tournaments/' . $id . '?tab=players');
return;
}
// Bulk import to Swiss API
$response = SwissApiService::bulkImportPlayers($tournament['swiss_api_tournament_id'], $playersData);
// Also register locally
foreach ($playersData as $player) {
$this->db->insert('tournament_registrations', [
'tournament_id' => $id,
'player_id' => $player['fideId'] ?: uniqid('imported_'),
'player_name' => $player['name'],
'rating' => $player['fideRatingStandard'],
'registered_at' => date('c'),
]);
}
if (SwissApiService::isSuccess($response)) {
Response::success('تم استيراد ' . count($playersData) . ' لاعب', '/tournaments/' . $id . '?tab=players');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=players');
}
}
public function withdrawPlayer(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$playerId = $params['playerId'] ?? '';
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id . '?tab=players');
return;
}
$response = SwissApiService::withdrawPlayer($tournament['swiss_api_tournament_id'], $playerId);
if (SwissApiService::isSuccess($response)) {
$this->db->update('tournament_registrations', [
'tournament_id' => "eq.{$id}",
'player_id' => "eq.{$playerId}",
], ['status' => 'withdrawn']);
Response::success('تم سحب اللاعب', '/tournaments/' . $id . '?tab=players');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=players');
}
}
// ==========================================
// TIEBREAKS & EXPORT
// ==========================================
public function updateTiebreaks(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament || empty($tournament['swiss_api_tournament_id'])) {
Response::error('لا يوجد ربط مع نظام السويسري', '/tournaments/' . $id);
return;
}
$tiebreaks = json_decode($_POST['tiebreaks'] ?? '[]', true);
if (empty($tiebreaks)) {
Response::error('يجب اختيار قاعدة واحدة على الأقل', '/tournaments/' . $id . '?tab=settings');
return;
}
$response = SwissApiService::updateTiebreaks($tournament['swiss_api_tournament_id'], $tiebreaks);
if (SwissApiService::isSuccess($response)) {
$this->db->update('el3ab_tournaments', ['id' => "eq.{$id}"], [
'tiebreak_rules' => json_encode($tiebreaks),
'updated_at' => date('c'),
]);
Response::success('تم تحديث قواعد كسر التعادل', '/tournaments/' . $id . '?tab=settings');
} else {
Response::error(SwissApiService::getError($response), '/tournaments/' . $id . '?tab=settings');
}
}
public function export(array $params, string $method): void
{
$id = $params['id'];
$format = $params['format'] ?? 'json';
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$id}"]);
if (!$tournament) {
http_response_code(404);
echo 'Tournament not found';
return;
}
$swissId = $tournament['swiss_api_tournament_id'] ?? null;
switch ($format) {
case 'trf':
if ($swissId) {
ExportService::downloadTRF($swissId);
} else {
http_response_code(400);
echo 'No Swiss API link for TRF export';
}
return;
case 'json':
if ($swissId) {
ExportService::downloadJSON($swissId);
} else {
ExportService::downloadLocalBracket($id);
}
return;
case 'crosstable':
if ($swissId) {
ExportService::downloadCrosstable($swissId);
} else {
http_response_code(400);
echo 'No Swiss API link for crosstable export';
}
return;
case 'full':
ExportService::downloadLocalBracket($id);
return;
default:
http_response_code(400);
echo 'Unknown export format';
}
}
// ==========================================
// MULTI-PHASE
// ==========================================
public function phases(array $params, string $method): void
{
$id = $params['id'];
$phaseManager = new PhaseManager();
$status = $phaseManager->getPhaseStatus($id);
Response::json($status);
}
public function startPhase(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$phaseId = $params['phaseId'] ?? '';
$phaseManager = new PhaseManager();
$result = $phaseManager->startPhase($phaseId);
if (isset($result['error'])) {
Response::error($result['error'], '/tournaments/' . $id . '?tab=phases');
} else {
Response::success('تم بدء المرحلة', '/tournaments/' . $id . '?tab=phases');
}
}
public function completePhase(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$phaseId = $params['phaseId'] ?? '';
$phaseManager = new PhaseManager();
$result = $phaseManager->completePhase($phaseId);
if (isset($result['error'])) {
Response::error($result['error'], '/tournaments/' . $id . '?tab=phases');
} else {
Response::success('تم إكمال المرحلة', '/tournaments/' . $id . '?tab=phases');
}
}
public function advancePlayers(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$phaseId = $params['phaseId'] ?? '';
$phase = $this->db->selectOne('tournament_phases', ['id' => "eq.{$phaseId}"]);
if (!$phase) {
Response::error('المرحلة غير موجودة', '/tournaments/' . $id . '?tab=phases');
return;
}
$phaseManager = new PhaseManager();
$result = $phaseManager->advancePlayersToNextPhase($id, $phase['phase_number']);
if (isset($result['error'])) {
Response::error($result['error'], '/tournaments/' . $id . '?tab=phases');
} else {
// Start next phase with qualified players
if (!empty($result['next_phase_id'])) {
$phaseManager->startPhase($result['next_phase_id']);
}
Response::success('تم ترقية ' . $result['advance_count'] . ' لاعب للمرحلة التالية', '/tournaments/' . $id . '?tab=phases');
}
}
// ==========================================
// BRACKET
// ==========================================
public function bracket(array $params, string $method): void
{
$id = $params['id'];
$brackets = $this->db->select('tournament_brackets', [
'tournament_id' => "eq.{$id}",
'order' => 'created_at.asc',
]);
if (empty($brackets)) {
Response::json(['error' => 'No brackets found'], 404);
return;
}
$bracketEngine = new BracketEngine();
$data = [];
foreach ($brackets as $bracket) {
$data[] = $bracketEngine->getBracketState($bracket['id']);
}
Response::json(['brackets' => $data]);
}
public function bracketResult(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$matchId = $params['matchId'] ?? '';
$result = $_POST['result'] ?? '';
$scoreA = $_POST['score_a'] ?? null;
$scoreB = $_POST['score_b'] ?? null;
if (!$matchId || !$result) {
Response::error('بيانات غير مكتملة', '/tournaments/' . $id . '?tab=bracket');
return;
}
$bracketEngine = new BracketEngine();
$outcome = $bracketEngine->submitMatchResult($matchId, $result, $scoreA, $scoreB);
if (isset($outcome['error'])) {
Response::error($outcome['error'], '/tournaments/' . $id . '?tab=bracket');
} else {
Response::success('تم تسجيل النتيجة', '/tournaments/' . $id . '?tab=bracket');
}
}
// ==========================================
// ARENA
// ==========================================
public function arenaPair(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$phase = $this->db->selectOne('tournament_phases', [
'tournament_id' => "eq.{$id}",
'type' => 'eq.arena',
'status' => 'eq.in_progress',
]);
if (!$phase) {
Response::json(['error' => 'No active arena phase'], 400);
return;
}
$arenaEngine = new ArenaEngine();
$result = $arenaEngine->pairNextAvailable($phase['id']);
Response::json($result);
}
public function arenaResult(array $params, string $method): void
{
Auth::requireCsrf();
$id = $params['id'];
$matchId = $_POST['match_id'] ?? '';
$result = $_POST['result'] ?? '';
if (!$matchId || !$result) {
Response::json(['error' => 'Missing match_id or result'], 400);
return;
}
$arenaEngine = new ArenaEngine();
$outcome = $arenaEngine->submitArenaResult($matchId, $result);
Response::json($outcome);
}
// ==========================================
// JSON API (AJAX)
// ==========================================
public function apiStandings(array $params, string $method): void
{
$this->standings($params, $method);
}
public function apiPairings(array $params, string $method): void
{
$this->pairings($params, $method);
}
public function apiBracket(array $params, string $method): void
{
$this->bracket($params, $method);
}
public function apiArena(array $params, string $method): void
{
$id = $params['id'];
$phase = $this->db->selectOne('tournament_phases', [
'tournament_id' => "eq.{$id}",
'type' => 'eq.arena',
]);
if (!$phase) {
Response::json(['error' => 'No arena phase found'], 404);
return;
}
$arenaEngine = new ArenaEngine();
$info = $arenaEngine->getArenaInfo($phase['id']);
$standings = $arenaEngine->getArenaStandings($phase['id']);
Response::json(array_merge($info, ['standings' => $standings]));
}
} }
<?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;
}
}
<?php
class BracketEngine
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function generateSingleElimination(string $tournamentId, string $phaseId, array $players, array $config = []): array
{
$playerCount = count($players);
$totalRounds = (int) ceil(log($playerCount, 2));
$bracketSize = (int) pow(2, $totalRounds);
$bracket = $this->db->insert('tournament_brackets', [
'tournament_id' => $tournamentId,
'phase_id' => $phaseId,
'bracket_type' => 'winners',
'total_rounds' => $totalRounds,
'current_round' => 1,
'config' => json_encode($config),
]);
$bracketId = $bracket['id'];
$seeded = $this->seedPlayers($players, $config['seed_method'] ?? 'rating', $bracketSize);
$matches = [];
$matchNumber = 1;
// Generate first round matches
for ($i = 0; $i < $bracketSize; $i += 2) {
$playerA = $seeded[$i] ?? null;
$playerB = $seeded[$i + 1] ?? null;
$isBye = ($playerA === null || $playerB === null);
$matchData = [
'bracket_id' => $bracketId,
'tournament_id' => $tournamentId,
'phase_id' => $phaseId,
'round_number' => 1,
'match_number' => $matchNumber,
'player_a_id' => $playerA['id'] ?? null,
'player_a_name' => $playerA['name'] ?? null,
'player_a_seed' => $playerA['seed'] ?? null,
'player_b_id' => $playerB['id'] ?? null,
'player_b_name' => $playerB['name'] ?? null,
'player_b_seed' => $playerB['seed'] ?? null,
'status' => $isBye ? 'bye' : 'ready',
'result' => $isBye ? ($playerA ? 'player_a_wins' : 'player_b_wins') : null,
'winner_id' => $isBye ? ($playerA['id'] ?? $playerB['id'] ?? null) : null,
];
$match = $this->db->insert('bracket_matches', $matchData);
$matches[] = $match;
$matchNumber++;
}
// Generate subsequent rounds (empty slots)
for ($round = 2; $round <= $totalRounds; $round++) {
$matchesInRound = (int) ($bracketSize / pow(2, $round));
for ($m = 0; $m < $matchesInRound; $m++) {
$match = $this->db->insert('bracket_matches', [
'bracket_id' => $bracketId,
'tournament_id' => $tournamentId,
'phase_id' => $phaseId,
'round_number' => $round,
'match_number' => $matchNumber,
'status' => 'pending',
]);
$matches[] = $match;
$matchNumber++;
}
}
// Link matches: winner of match X feeds into next round
$this->linkBracketMatches($bracketId, $totalRounds, $bracketSize);
// Auto-advance byes
$this->advanceByes($bracketId);
return ['bracket_id' => $bracketId, 'total_rounds' => $totalRounds, 'matches_created' => count($matches)];
}
public function generateDoubleElimination(string $tournamentId, string $phaseId, array $players, array $config = []): array
{
// Winners bracket
$winnersResult = $this->generateSingleElimination($tournamentId, $phaseId, $players, $config);
$winnersBracketId = $winnersResult['bracket_id'];
$playerCount = count($players);
$totalRounds = (int) ceil(log($playerCount, 2));
$bracketSize = (int) pow(2, $totalRounds);
// Losers bracket: 2*(totalRounds-1) rounds
$losersRounds = 2 * ($totalRounds - 1);
$losersBracket = $this->db->insert('tournament_brackets', [
'tournament_id' => $tournamentId,
'phase_id' => $phaseId,
'bracket_type' => 'losers',
'total_rounds' => $losersRounds,
'current_round' => 0,
'config' => json_encode($config),
]);
$losersBracketId = $losersBracket['id'];
$matchNumber = 1;
// Generate losers bracket empty matches
$currentSlots = $bracketSize / 2;
for ($round = 1; $round <= $losersRounds; $round++) {
if ($round % 2 === 0) {
$currentSlots = (int) ($currentSlots / 2);
}
$matchesInRound = max(1, (int) ($currentSlots / 2));
if ($round % 2 === 1 && $round > 1) {
$matchesInRound = (int) $currentSlots;
}
$matchesInRound = max(1, (int) ($bracketSize / pow(2, (int) ceil($round / 2) + 1)));
for ($m = 0; $m < $matchesInRound; $m++) {
$this->db->insert('bracket_matches', [
'bracket_id' => $losersBracketId,
'tournament_id' => $tournamentId,
'phase_id' => $phaseId,
'round_number' => $round,
'match_number' => $matchNumber,
'status' => 'pending',
]);
$matchNumber++;
}
}
// Grand final
$this->db->insert('bracket_matches', [
'bracket_id' => $winnersBracketId,
'tournament_id' => $tournamentId,
'phase_id' => $phaseId,
'round_number' => $totalRounds + 1,
'match_number' => 9999,
'status' => 'pending',
'metadata' => json_encode(['type' => 'grand_final']),
]);
return [
'winners_bracket_id' => $winnersBracketId,
'losers_bracket_id' => $losersBracketId,
'total_rounds_winners' => $totalRounds,
'total_rounds_losers' => $losersRounds,
];
}
public function generateGroupStage(string $tournamentId, string $phaseId, array $players, int $groupCount, int $advancePerGroup, array $config = []): array
{
$seeded = $this->seedPlayers($players, $config['seed_method'] ?? 'rating', count($players));
$groups = $this->distributeToGroups($seeded, $groupCount);
$groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
$brackets = [];
foreach ($groups as $gi => $groupPlayers) {
$groupName = $groupNames[$gi] ?? ('G' . ($gi + 1));
$bracket = $this->db->insert('tournament_brackets', [
'tournament_id' => $tournamentId,
'phase_id' => $phaseId,
'bracket_type' => 'group',
'group_name' => $groupName,
'total_rounds' => count($groupPlayers) - 1,
'current_round' => 0,
'config' => json_encode(array_merge($config, ['advance_count' => $advancePerGroup])),
]);
$bracketId = $bracket['id'];
$roundRobinPairings = $this->generateRoundRobin($groupPlayers);
$matchNumber = 1;
foreach ($roundRobinPairings as $roundNum => $roundPairings) {
foreach ($roundPairings as $pairing) {
$this->db->insert('bracket_matches', [
'bracket_id' => $bracketId,
'tournament_id' => $tournamentId,
'phase_id' => $phaseId,
'round_number' => $roundNum + 1,
'match_number' => $matchNumber,
'player_a_id' => $pairing[0]['id'],
'player_a_name' => $pairing[0]['name'],
'player_a_seed' => $pairing[0]['seed'] ?? null,
'player_b_id' => $pairing[1]['id'],
'player_b_name' => $pairing[1]['name'],
'player_b_seed' => $pairing[1]['seed'] ?? null,
'status' => $roundNum === 0 ? 'ready' : 'pending',
]);
$matchNumber++;
}
}
$brackets[] = ['bracket_id' => $bracketId, 'group_name' => $groupName, 'players' => count($groupPlayers)];
}
return ['groups' => $brackets, 'advance_per_group' => $advancePerGroup];
}
public function submitMatchResult(string $matchId, string $result, ?string $scoreA = null, ?string $scoreB = null): array
{
$match = $this->db->selectOne('bracket_matches', ['id' => "eq.{$matchId}"]);
if (!$match) {
return ['error' => 'Match not found'];
}
$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,
'score_a' => $scoreA,
'score_b' => $scoreB,
'winner_id' => $winnerId,
'status' => 'completed',
'completed_at' => date('c'),
]);
// Advance winner to next match
if ($winnerId && $match['next_match_id']) {
$slot = $match['next_match_slot'] ?? 'player_a';
$winnerName = $result === 'player_a_wins' ? $match['player_a_name'] : $match['player_b_name'];
$winnerSeed = $result === 'player_a_wins' ? $match['player_a_seed'] : $match['player_b_seed'];
$updateData = [
$slot . '_id' => $winnerId,
$slot . '_name' => $winnerName,
$slot . '_seed' => $winnerSeed,
];
$nextMatch = $this->db->selectOne('bracket_matches', ['id' => "eq.{$match['next_match_id']}"]);
if ($nextMatch) {
$this->db->update('bracket_matches', ['id' => "eq.{$match['next_match_id']}"], $updateData);
// Check if both players are now set
$updated = $this->db->selectOne('bracket_matches', ['id' => "eq.{$match['next_match_id']}"]);
if ($updated && $updated['player_a_id'] && $updated['player_b_id']) {
$this->db->update('bracket_matches', ['id' => "eq.{$match['next_match_id']}"], ['status' => 'ready']);
}
}
}
// Send loser to losers bracket if applicable
if ($match['loser_next_match_id']) {
$loserId = $result === 'player_a_wins' ? $match['player_b_id'] : $match['player_a_id'];
$loserName = $result === 'player_a_wins' ? $match['player_b_name'] : $match['player_a_name'];
$loserSeed = $result === 'player_a_wins' ? $match['player_b_seed'] : $match['player_a_seed'];
$loserSlot = $match['loser_next_match_slot'] ?? 'player_a';
$this->db->update('bracket_matches', ['id' => "eq.{$match['loser_next_match_id']}"], [
$loserSlot . '_id' => $loserId,
$loserSlot . '_name' => $loserName,
$loserSlot . '_seed' => $loserSeed,
]);
}
// Update bracket current round
$this->updateBracketProgress($match['bracket_id']);
return ['success' => true, 'winner_id' => $winnerId];
}
public function getBracketState(string $bracketId): array
{
$bracket = $this->db->selectOne('tournament_brackets', ['id' => "eq.{$bracketId}"]);
if (!$bracket) {
return [];
}
$matches = $this->db->select('bracket_matches', [
'bracket_id' => "eq.{$bracketId}",
'order' => 'round_number.asc,match_number.asc',
]);
$rounds = [];
foreach ($matches as $match) {
$rounds[$match['round_number']][] = $match;
}
return [
'bracket' => $bracket,
'rounds' => $rounds,
'total_rounds' => $bracket['total_rounds'],
'current_round' => $bracket['current_round'],
];
}
public function getGroupStandings(string $bracketId): array
{
$matches = $this->db->select('bracket_matches', [
'bracket_id' => "eq.{$bracketId}",
'status' => 'eq.completed',
]);
$standings = [];
foreach ($matches as $match) {
if (!isset($standings[$match['player_a_id']])) {
$standings[$match['player_a_id']] = [
'id' => $match['player_a_id'],
'name' => $match['player_a_name'],
'played' => 0, 'wins' => 0, 'draws' => 0, 'losses' => 0, 'points' => 0,
];
}
if (!isset($standings[$match['player_b_id']])) {
$standings[$match['player_b_id']] = [
'id' => $match['player_b_id'],
'name' => $match['player_b_name'],
'played' => 0, 'wins' => 0, 'draws' => 0, 'losses' => 0, 'points' => 0,
];
}
$standings[$match['player_a_id']]['played']++;
$standings[$match['player_b_id']]['played']++;
switch ($match['result']) {
case 'player_a_wins':
$standings[$match['player_a_id']]['wins']++;
$standings[$match['player_a_id']]['points'] += 3;
$standings[$match['player_b_id']]['losses']++;
break;
case 'player_b_wins':
$standings[$match['player_b_id']]['wins']++;
$standings[$match['player_b_id']]['points'] += 3;
$standings[$match['player_a_id']]['losses']++;
break;
case 'draw':
$standings[$match['player_a_id']]['draws']++;
$standings[$match['player_a_id']]['points'] += 1;
$standings[$match['player_b_id']]['draws']++;
$standings[$match['player_b_id']]['points'] += 1;
break;
}
}
usort($standings, fn($a, $b) => $b['points'] <=> $a['points'] ?: $b['wins'] <=> $a['wins']);
$rank = 1;
foreach ($standings as &$s) {
$s['rank'] = $rank++;
}
return array_values($standings);
}
// --- Private helpers ---
private function seedPlayers(array $players, string $method, int $bracketSize): array
{
switch ($method) {
case 'rating':
usort($players, fn($a, $b) => ($b['rating'] ?? 0) <=> ($a['rating'] ?? 0));
break;
case 'random':
shuffle($players);
break;
case 'manual':
break;
}
$seeded = [];
foreach ($players as $i => $player) {
$player['seed'] = $i + 1;
$seeded[] = $player;
}
// Pad with nulls for byes
while (count($seeded) < $bracketSize) {
$seeded[] = null;
}
// Standard bracket seeding (1v16, 8v9, 5v12, 4v13, etc.)
return $this->standardBracketOrder($seeded);
}
private function standardBracketOrder(array $seeded): array
{
$n = count($seeded);
if ($n <= 2) return $seeded;
$order = [0];
$size = 1;
while ($size < $n / 2) {
$temp = [];
foreach ($order as $pos) {
$temp[] = $pos;
$temp[] = $size * 2 - 1 - $pos;
}
$order = $temp;
$size *= 2;
}
$result = [];
foreach ($order as $pos) {
$result[] = $seeded[$pos] ?? null;
$result[] = $seeded[$n - 1 - $pos] ?? null;
}
return array_slice($result, 0, $n);
}
private function distributeToGroups(array $players, int $groupCount): array
{
$groups = array_fill(0, $groupCount, []);
// Snake distribution for balanced groups
foreach ($players as $i => $player) {
$row = (int) floor($i / $groupCount);
$col = $i % $groupCount;
if ($row % 2 === 1) {
$col = $groupCount - 1 - $col;
}
$groups[$col][] = $player;
}
return $groups;
}
private function generateRoundRobin(array $players): array
{
$n = count($players);
if ($n % 2 !== 0) {
$players[] = ['id' => 'BYE', 'name' => 'BYE', 'seed' => null];
$n++;
}
$rounds = [];
$fixed = $players[0];
$rotating = array_slice($players, 1);
for ($round = 0; $round < $n - 1; $round++) {
$roundPairings = [];
$current = array_merge([$fixed], $rotating);
for ($i = 0; $i < $n / 2; $i++) {
$a = $current[$i];
$b = $current[$n - 1 - $i];
if ($a['id'] !== 'BYE' && $b['id'] !== 'BYE') {
$roundPairings[] = [$a, $b];
}
}
$rounds[] = $roundPairings;
// Rotate: last element moves to second position
$last = array_pop($rotating);
array_unshift($rotating, $last);
}
return $rounds;
}
private function linkBracketMatches(string $bracketId, int $totalRounds, int $bracketSize): void
{
$matches = $this->db->select('bracket_matches', [
'bracket_id' => "eq.{$bracketId}",
'order' => 'round_number.asc,match_number.asc',
]);
$byRound = [];
foreach ($matches as $m) {
$byRound[$m['round_number']][] = $m;
}
for ($round = 1; $round < $totalRounds; $round++) {
$currentRoundMatches = $byRound[$round] ?? [];
$nextRoundMatches = $byRound[$round + 1] ?? [];
foreach ($currentRoundMatches as $i => $match) {
$nextIdx = (int) floor($i / 2);
if (!isset($nextRoundMatches[$nextIdx])) continue;
$slot = ($i % 2 === 0) ? 'player_a' : 'player_b';
$this->db->update('bracket_matches', ['id' => "eq.{$match['id']}"], [
'next_match_id' => $nextRoundMatches[$nextIdx]['id'],
'next_match_slot' => $slot,
]);
}
}
}
private function advanceByes(string $bracketId): void
{
$byes = $this->db->select('bracket_matches', [
'bracket_id' => "eq.{$bracketId}",
'status' => 'eq.bye',
]);
foreach ($byes as $bye) {
if ($bye['next_match_id'] && $bye['winner_id']) {
$slot = $bye['next_match_slot'] ?? 'player_a';
$winnerName = $bye['result'] === 'player_a_wins' ? $bye['player_a_name'] : $bye['player_b_name'];
$winnerSeed = $bye['result'] === 'player_a_wins' ? $bye['player_a_seed'] : $bye['player_b_seed'];
$this->db->update('bracket_matches', ['id' => "eq.{$bye['next_match_id']}"], [
$slot . '_id' => $bye['winner_id'],
$slot . '_name' => $winnerName,
$slot . '_seed' => $winnerSeed,
]);
$nextMatch = $this->db->selectOne('bracket_matches', ['id' => "eq.{$bye['next_match_id']}"]);
if ($nextMatch && $nextMatch['player_a_id'] && $nextMatch['player_b_id']) {
$this->db->update('bracket_matches', ['id' => "eq.{$bye['next_match_id']}"], ['status' => 'ready']);
}
}
}
}
private function updateBracketProgress(string $bracketId): void
{
$bracket = $this->db->selectOne('tournament_brackets', ['id' => "eq.{$bracketId}"]);
if (!$bracket) return;
for ($round = 1; $round <= $bracket['total_rounds']; $round++) {
$pending = $this->db->count('bracket_matches', [
'bracket_id' => "eq.{$bracketId}",
'round_number' => "eq.{$round}",
'status' => 'neq.completed',
]);
// Exclude byes from pending check
$byes = $this->db->count('bracket_matches', [
'bracket_id' => "eq.{$bracketId}",
'round_number' => "eq.{$round}",
'status' => 'eq.bye',
]);
if (($pending - $byes) > 0) {
$this->db->update('tournament_brackets', ['id' => "eq.{$bracketId}"], [
'current_round' => $round,
'updated_at' => date('c'),
]);
return;
}
}
$this->db->update('tournament_brackets', ['id' => "eq.{$bracketId}"], [
'current_round' => $bracket['total_rounds'],
'updated_at' => date('c'),
]);
}
}
<?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;
}
}
<?php
class PhaseManager
{
private Database $db;
private BracketEngine $bracketEngine;
public function __construct()
{
$this->db = Database::getInstance();
$this->bracketEngine = new BracketEngine();
}
public function initializePhases(string $tournamentId, array $phaseConfigs): array
{
$phases = [];
foreach ($phaseConfigs as $i => $config) {
$phase = $this->db->insert('tournament_phases', [
'tournament_id' => $tournamentId,
'phase_number' => $i + 1,
'name' => $config['name'] ?? ('Phase ' . ($i + 1)),
'name_ar' => $config['name_ar'] ?? null,
'type' => $config['type'],
'config' => json_encode($config['settings'] ?? []),
'advancement_rule' => json_encode($config['advancement'] ?? []),
'status' => 'pending',
]);
$phases[] = $phase;
}
$this->db->update('el3ab_tournaments', ['id' => "eq.{$tournamentId}"], [
'tournament_mode' => 'multi_phase',
'total_phases' => count($phaseConfigs),
'current_phase' => 1,
'phase_config' => json_encode($phaseConfigs),
'updated_at' => date('c'),
]);
return $phases;
}
public function startPhase(string $phaseId): array
{
$phase = $this->db->selectOne('tournament_phases', ['id' => "eq.{$phaseId}"]);
if (!$phase) {
return ['error' => 'Phase not found'];
}
if ($phase['status'] !== 'pending') {
return ['error' => 'Phase already started or completed'];
}
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$phase['tournament_id']}"]);
$config = json_decode($phase['config'] ?? '{}', true);
switch ($phase['type']) {
case 'swiss':
$result = $this->startSwissPhase($phase, $tournament, $config);
break;
case 'single_elimination':
$result = $this->startEliminationPhase($phase, $tournament, $config, 'single');
break;
case 'double_elimination':
$result = $this->startEliminationPhase($phase, $tournament, $config, 'double');
break;
case 'group_stage':
$result = $this->startGroupPhase($phase, $tournament, $config);
break;
case 'round_robin':
$result = $this->startRoundRobinPhase($phase, $tournament, $config);
break;
case 'arena':
$result = $this->startArenaPhase($phase, $tournament, $config);
break;
default:
return ['error' => 'Unknown phase type: ' . $phase['type']];
}
$this->db->update('tournament_phases', ['id' => "eq.{$phaseId}"], [
'status' => 'in_progress',
'started_at' => date('c'),
'updated_at' => date('c'),
]);
$this->db->update('el3ab_tournaments', ['id' => "eq.{$phase['tournament_id']}"], [
'current_phase' => $phase['phase_number'],
'updated_at' => date('c'),
]);
return array_merge(['success' => true], $result);
}
public function completePhase(string $phaseId): array
{
$phase = $this->db->selectOne('tournament_phases', ['id' => "eq.{$phaseId}"]);
if (!$phase) {
return ['error' => 'Phase not found'];
}
if ($phase['status'] !== 'in_progress') {
return ['error' => 'Phase is not in progress'];
}
$this->db->update('tournament_phases', ['id' => "eq.{$phaseId}"], [
'status' => 'completed',
'completed_at' => date('c'),
'updated_at' => date('c'),
]);
return ['success' => true];
}
public function advancePlayersToNextPhase(string $tournamentId, int $fromPhaseNumber): array
{
$currentPhase = $this->db->selectOne('tournament_phases', [
'tournament_id' => "eq.{$tournamentId}",
'phase_number' => "eq.{$fromPhaseNumber}",
]);
$nextPhase = $this->db->selectOne('tournament_phases', [
'tournament_id' => "eq.{$tournamentId}",
'phase_number' => "eq." . ($fromPhaseNumber + 1),
]);
if (!$currentPhase || !$nextPhase) {
return ['error' => 'Phase not found'];
}
if ($currentPhase['status'] !== 'completed') {
return ['error' => 'Current phase must be completed before advancing'];
}
$advancementRule = json_decode($currentPhase['advancement_rule'] ?? '{}', true);
$advanceCount = $advancementRule['count'] ?? 8;
$method = $advancementRule['method'] ?? 'standings';
$qualifiedPlayers = $this->getQualifiedPlayers($currentPhase, $advanceCount, $method);
if (empty($qualifiedPlayers)) {
return ['error' => 'No qualified players found'];
}
return [
'success' => true,
'qualified_players' => $qualifiedPlayers,
'advance_count' => count($qualifiedPlayers),
'next_phase_id' => $nextPhase['id'],
];
}
public function getPhaseStatus(string $tournamentId): array
{
$phases = $this->db->select('tournament_phases', [
'tournament_id' => "eq.{$tournamentId}",
'order' => 'phase_number.asc',
]);
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$tournamentId}"]);
return [
'phases' => $phases,
'current_phase' => $tournament['current_phase'] ?? 1,
'total_phases' => $tournament['total_phases'] ?? 1,
'tournament_mode' => $tournament['tournament_mode'] ?? 'single',
];
}
public function getPhaseDetails(string $phaseId): array
{
$phase = $this->db->selectOne('tournament_phases', ['id' => "eq.{$phaseId}"]);
if (!$phase) return [];
$brackets = $this->db->select('tournament_brackets', [
'phase_id' => "eq.{$phaseId}",
'order' => 'created_at.asc',
]);
$matchesCount = $this->db->count('bracket_matches', ['phase_id' => "eq.{$phaseId}"]);
$completedMatches = $this->db->count('bracket_matches', [
'phase_id' => "eq.{$phaseId}",
'status' => 'eq.completed',
]);
return [
'phase' => $phase,
'brackets' => $brackets,
'total_matches' => $matchesCount,
'completed_matches' => $completedMatches,
'progress' => $matchesCount > 0 ? round(($completedMatches / $matchesCount) * 100) : 0,
];
}
// --- Private phase starters ---
private function startSwissPhase(array $phase, array $tournament, array $config): array
{
$swissOrgId = $tournament['swiss_org_id'] ?? null;
$swissEventId = $tournament['swiss_event_id'] ?? null;
if (!$swissEventId) {
return ['error' => 'No Swiss API event linked'];
}
$tournamentData = [
'name' => $phase['name'] . ' - ' . $tournament['name'],
'tournamentType' => 'swiss',
'roundsNumber' => $config['rounds'] ?? 5,
'maxPlayers' => $config['max_players'] ?? (int) ($tournament['max_players'] ?? 200),
];
$response = SwissApiService::createTournament($swissEventId, $tournamentData);
if (!SwissApiService::isSuccess($response)) {
return ['error' => SwissApiService::getError($response)];
}
$swissTournamentId = $response['body']['id'] ?? null;
$this->db->update('tournament_phases', ['id' => "eq.{$phase['id']}"], [
'swiss_api_tournament_id' => $swissTournamentId,
]);
return ['swiss_tournament_id' => $swissTournamentId];
}
private function startEliminationPhase(array $phase, array $tournament, array $config, string $type): array
{
$players = $this->getPhasePlayers($phase);
if (empty($players)) {
return ['error' => 'No players for elimination phase'];
}
if ($type === 'double') {
return $this->bracketEngine->generateDoubleElimination(
$tournament['id'], $phase['id'], $players, $config
);
}
return $this->bracketEngine->generateSingleElimination(
$tournament['id'], $phase['id'], $players, $config
);
}
private function startGroupPhase(array $phase, array $tournament, array $config): array
{
$players = $this->getPhasePlayers($phase);
if (empty($players)) {
return ['error' => 'No players for group phase'];
}
$groupCount = $config['groups'] ?? 4;
$advancePerGroup = $config['advance_per_group'] ?? 2;
return $this->bracketEngine->generateGroupStage(
$tournament['id'], $phase['id'], $players, $groupCount, $advancePerGroup, $config
);
}
private function startRoundRobinPhase(array $phase, array $tournament, array $config): array
{
$players = $this->getPhasePlayers($phase);
if (empty($players)) {
return ['error' => 'No players for round robin phase'];
}
return $this->bracketEngine->generateGroupStage(
$tournament['id'], $phase['id'], $players, 1, count($players), $config
);
}
private function startArenaPhase(array $phase, array $tournament, array $config): array
{
$bracket = $this->db->insert('tournament_brackets', [
'tournament_id' => $tournament['id'],
'phase_id' => $phase['id'],
'bracket_type' => 'winners',
'total_rounds' => 0,
'current_round' => 0,
'config' => json_encode(array_merge($config, [
'type' => 'arena',
'duration_minutes' => $config['duration_minutes'] ?? 60,
'started_at' => date('c'),
])),
]);
return ['bracket_id' => $bracket['id'], 'type' => 'arena'];
}
private function getPhasePlayers(array $phase): array
{
$tournament = $this->db->selectOne('el3ab_tournaments', ['id' => "eq.{$phase['tournament_id']}"]);
if ($phase['phase_number'] === 1) {
$registrations = $this->db->select('tournament_registrations', [
'tournament_id' => "eq.{$phase['tournament_id']}",
]);
$players = [];
foreach ($registrations as $reg) {
$players[] = [
'id' => $reg['player_id'],
'name' => $reg['player_name'] ?? $reg['player_id'],
'rating' => $reg['rating'] ?? 1500,
];
}
return $players;
}
// For subsequent phases, get qualified players from previous phase
$prevPhase = $this->db->selectOne('tournament_phases', [
'tournament_id' => "eq.{$phase['tournament_id']}",
'phase_number' => "eq." . ($phase['phase_number'] - 1),
]);
if (!$prevPhase) return [];
$advancementRule = json_decode($prevPhase['advancement_rule'] ?? '{}', true);
$advanceCount = $advancementRule['count'] ?? 8;
return $this->getQualifiedPlayers($prevPhase, $advanceCount, $advancementRule['method'] ?? 'standings');
}
private function getQualifiedPlayers(array $phase, int $count, string $method): array
{
if ($phase['type'] === 'swiss' && $phase['swiss_api_tournament_id']) {
$response = SwissApiService::getStandings($phase['swiss_api_tournament_id']);
if (SwissApiService::isSuccess($response)) {
$standings = $response['body'] ?? [];
$qualified = array_slice($standings, 0, $count);
return array_map(fn($s) => [
'id' => $s['player_id'] ?? $s['id'] ?? '',
'name' => $s['player_name'] ?? $s['name'] ?? '',
'rating' => $s['rating'] ?? 1500,
'seed' => $s['rank'] ?? null,
], $qualified);
}
}
if (in_array($phase['type'], ['group_stage', 'round_robin'])) {
$brackets = $this->db->select('tournament_brackets', [
'phase_id' => "eq.{$phase['id']}",
'bracket_type' => 'eq.group',
]);
$allQualified = [];
$config = json_decode($brackets[0]['config'] ?? '{}', true);
$advancePerGroup = $config['advance_count'] ?? 2;
foreach ($brackets as $bracket) {
$standings = $this->bracketEngine->getGroupStandings($bracket['id']);
$qualified = array_slice($standings, 0, $advancePerGroup);
foreach ($qualified as $q) {
$allQualified[] = [
'id' => $q['id'],
'name' => $q['name'],
'rating' => 1500,
'seed' => count($allQualified) + 1,
];
}
}
return $allQualified;
}
if (in_array($phase['type'], ['single_elimination', 'double_elimination'])) {
$brackets = $this->db->select('tournament_brackets', [
'phase_id' => "eq.{$phase['id']}",
'bracket_type' => 'eq.winners',
]);
if (!empty($brackets)) {
$finalMatch = $this->db->selectOne('bracket_matches', [
'bracket_id' => "eq.{$brackets[0]['id']}",
'round_number' => "eq.{$brackets[0]['total_rounds']}",
'status' => 'eq.completed',
]);
if ($finalMatch && $finalMatch['winner_id']) {
return [[
'id' => $finalMatch['winner_id'],
'name' => $finalMatch['result'] === 'player_a_wins' ? $finalMatch['player_a_name'] : $finalMatch['player_b_name'],
'rating' => 1500,
'seed' => 1,
]];
}
}
}
return [];
}
}
<?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>
......
...@@ -13,7 +13,27 @@ $formatLabels = [ ...@@ -13,7 +13,27 @@ $formatLabels = [
'round_robin' => 'دوري كامل', 'round_robin' => 'دوري كامل',
'single_elimination' => 'خروج مباشر', 'single_elimination' => 'خروج مباشر',
'double_elimination' => 'خروج مزدوج', 'double_elimination' => 'خروج مزدوج',
'arena' => 'أرينا',
'group_stage' => 'مجموعات',
]; ];
$isMultiPhase = ($tournament['tournament_mode'] ?? 'single') === 'multi_phase';
$tabs = ['info' => 'معلومات', 'players' => 'اللاعبون', 'rounds' => 'الجولات', 'standings' => 'الترتيب'];
if ($isMultiPhase) {
$tabs['phases'] = 'المراحل';
}
if (!empty($brackets)) {
$tabs['bracket'] = 'القوسية';
}
$hasArena = false;
foreach (($phases ?? []) as $p) {
if ($p['type'] === 'arena') { $hasArena = true; break; }
}
if ($hasArena) {
$tabs['arena'] = 'أرينا';
}
$tabs['arbiter'] = 'أدوات الحكم';
?> ?>
<div class="content-header"> <div class="content-header">
...@@ -23,7 +43,13 @@ $formatLabels = [ ...@@ -23,7 +43,13 @@ $formatLabels = [
</a> </a>
<div> <div>
<h1><?= View::e($tournament['name']) ?></h1> <h1><?= View::e($tournament['name']) ?></h1>
<span class="badge <?= $s[1] ?> mt-1"><?= $s[0] ?></span> <div class="flex gap-2 mt-1">
<span class="badge <?= $s[1] ?>"><?= $s[0] ?></span>
<?php if ($isMultiPhase): ?>
<span class="badge badge-info">متعدد المراحل (<?= $tournament['current_phase'] ?? 1 ?>/<?= $tournament['total_phases'] ?? 1 ?>)</span>
<?php endif; ?>
<span class="badge badge-default"><?= $formatLabels[$tournament['format'] ?? ''] ?? $tournament['format'] ?></span>
</div>
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
...@@ -38,13 +64,15 @@ $formatLabels = [ ...@@ -38,13 +64,15 @@ $formatLabels = [
<?php endif; ?> <?php endif; ?>
<?php if ($tournament['status'] === 'in_progress'): ?> <?php if ($tournament['status'] === 'in_progress'): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/generate-round" style="display:inline;"> <?php if (!$isMultiPhase): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/rounds/generate" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-primary"> <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> <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> </button>
</form> </form>
<?php endif; ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/complete" style="display:inline;"> <form method="POST" action="/tournaments/<?= $tournament['id'] ?>/complete" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
...@@ -76,9 +104,9 @@ $formatLabels = [ ...@@ -76,9 +104,9 @@ $formatLabels = [
<!-- Tabs --> <!-- Tabs -->
<div class="tabs mb-5"> <div class="tabs mb-5">
<a href="/tournaments/<?= $tournament['id'] ?>?tab=info" class="tab <?= $tab === 'info' ? 'active' : '' ?>">معلومات</a> <?php foreach ($tabs as $key => $label): ?>
<a href="/tournaments/<?= $tournament['id'] ?>?tab=rounds" class="tab <?= $tab === 'rounds' ? 'active' : '' ?>">الجولات</a> <a href="/tournaments/<?= $tournament['id'] ?>?tab=<?= $key ?>" class="tab <?= $tab === $key ? 'active' : '' ?>"><?= $label ?></a>
<a href="/tournaments/<?= $tournament['id'] ?>?tab=standings" class="tab <?= $tab === 'standings' ? 'active' : '' ?>">الترتيب</a> <?php endforeach; ?>
</div> </div>
<!-- Tab: Info --> <!-- Tab: Info -->
...@@ -95,6 +123,10 @@ $formatLabels = [ ...@@ -95,6 +123,10 @@ $formatLabels = [
<span class="info-label">النظام</span> <span class="info-label">النظام</span>
<span class="info-value"><?= $formatLabels[$tournament['format'] ?? ''] ?? '-' ?></span> <span class="info-value"><?= $formatLabels[$tournament['format'] ?? ''] ?? '-' ?></span>
</div> </div>
<div class="info-item">
<span class="info-label">الوضع</span>
<span class="info-value"><?= $isMultiPhase ? 'متعدد المراحل' : 'مرحلة واحدة' ?></span>
</div>
<div class="info-item"> <div class="info-item">
<span class="info-label">عدد الجولات</span> <span class="info-label">عدد الجولات</span>
<span class="info-value"><?= $tournament['rounds_count'] ?? '-' ?></span> <span class="info-value"><?= $tournament['rounds_count'] ?? '-' ?></span>
...@@ -155,18 +187,32 @@ $formatLabels = [ ...@@ -155,18 +187,32 @@ $formatLabels = [
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($tournament['banner_url'])): ?> <?php if ($isMultiPhase && !empty($phases)): ?>
<div class="card" style="grid-column: 1 / -1;"> <div class="card" style="grid-column: 1 / -1;">
<h3 class="card-title">البانر</h3> <h3 class="card-title">المراحل</h3>
<img src="<?= View::e($tournament['banner_url']) ?>" alt="بانر البطولة" class="banner-preview"> <?php include __DIR__ . '/_phase_timeline.php'; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?>
<!-- Players List --> <!-- Tab: Players -->
<?php if (!empty($players)): ?> <?php if ($tab === 'players'): ?>
<div class="card mt-5"> <div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="card-title">اللاعبون المسجلون (<?= count($players) ?>)</h3> <h3 class="card-title">اللاعبون المسجلون (<?= count($players) ?>)</h3>
<?php if (in_array($tournament['status'], ['draft', 'registration', 'in_progress'])): ?>
<button type="button" class="btn btn-sm btn-primary" onclick="document.getElementById('importSection').style.display = document.getElementById('importSection').style.display === 'none' ? 'block' : 'none'">
استيراد لاعبين
</button>
<?php endif; ?>
</div>
<div id="importSection" style="display:none;" class="mb-4">
<?php include __DIR__ . '/_player_import.php'; ?>
</div>
<?php if (!empty($players)): ?>
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
...@@ -174,22 +220,48 @@ $formatLabels = [ ...@@ -174,22 +220,48 @@ $formatLabels = [
<th>اللاعب</th> <th>اللاعب</th>
<th>التقييم</th> <th>التقييم</th>
<th>تاريخ التسجيل</th> <th>تاريخ التسجيل</th>
<th>الحالة</th>
<?php if ($tournament['status'] === 'in_progress'): ?>
<th></th>
<?php endif; ?>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($players as $i => $player): ?> <?php foreach ($players as $i => $player): ?>
<tr> <tr>
<td><?= $i + 1 ?></td> <td><?= $i + 1 ?></td>
<td><?= View::e($player['player_name'] ?? $player['player_id']) ?></td> <td class="font-medium"><?= View::e($player['player_name'] ?? $player['player_id']) ?></td>
<td class="tabular-nums"><?= $player['rating'] ?? 1500 ?></td> <td class="tabular-nums"><?= $player['rating'] ?? 1500 ?></td>
<td class="text-xs text-muted"><?= $player['registered_at'] ? date('Y/m/d H:i', strtotime($player['registered_at'])) : '-' ?></td> <td class="text-xs text-muted"><?= $player['registered_at'] ? date('Y/m/d H:i', strtotime($player['registered_at'])) : '-' ?></td>
<td>
<?php if (($player['status'] ?? '') === 'withdrawn'): ?>
<span class="badge badge-danger badge-sm">منسحب</span>
<?php else: ?>
<span class="badge badge-success badge-sm">مسجل</span>
<?php endif; ?>
</td>
<?php if ($tournament['status'] === 'in_progress'): ?>
<td>
<?php if (($player['status'] ?? '') !== 'withdrawn'): ?>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/players/<?= $player['player_id'] ?>/withdraw" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="btn btn-xs btn-ghost text-danger" onclick="return confirm('سحب اللاعب؟')">سحب</button>
</form>
<?php endif; ?>
</td>
<?php endif; ?>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
<?php else: ?>
<div class="empty-state p-8">
<h3 class="empty-state-title">لا يوجد لاعبون مسجلون</h3>
<p class="empty-state-text">استخدم زر "استيراد لاعبين" لإضافة لاعبين</p>
</div>
<?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php endif; ?>
<!-- Tab: Rounds --> <!-- Tab: Rounds -->
<?php if ($tab === 'rounds'): ?> <?php if ($tab === 'rounds'): ?>
...@@ -203,54 +275,52 @@ $formatLabels = [ ...@@ -203,54 +275,52 @@ $formatLabels = [
<?php else: ?> <?php else: ?>
<?php foreach ($rounds as $round): ?> <?php foreach ($rounds as $round): ?>
<div class="round-card card mb-4"> <div class="round-card card mb-4">
<div class="round-header"> <div class="round-header flex justify-between items-center p-4">
<h3 class="round-title"> <h3 class="round-title">
الجولة <?= $round['round_number'] ?> الجولة <?= $round['round_number'] ?>
<?php if ($round['status'] === 'completed'): ?> <?php if ($round['status'] === 'completed'): ?>
<span class="badge badge-success">مكتملة</span> <span class="badge badge-success">مكتملة</span>
<?php elseif ($round['status'] === 'unpaired'): ?>
<span class="badge badge-danger">ملغاة</span>
<?php else: ?> <?php else: ?>
<span class="badge badge-warning">جارية</span> <span class="badge badge-warning">جارية</span>
<?php endif; ?> <?php endif; ?>
</h3> </h3>
<?php if (!empty($round['swiss_round_id']) && $round['status'] !== 'completed'): ?>
<button type="button" class="btn btn-sm btn-primary" onclick="loadRoundPairings('<?= $round['id'] ?>', '<?= $round['swiss_round_id'] ?>')">
عرض المواجهات
</button>
<?php endif; ?>
</div> </div>
<div class="round-pairings-container" id="roundPairings_<?= $round['id'] ?>">
<?php <?php
$pairings = json_decode($round['pairings'] ?? '[]', true); $pairingsJson = json_decode($round['pairings'] ?? '[]', true);
$results = json_decode($round['results'] ?? '[]', true); $results = json_decode($round['results'] ?? '[]', true);
$resultsMap = [];
foreach ($results as $r) {
$resultsMap[$r['pairing_index']] = $r['result'];
}
?> ?>
<?php if (!empty($results)): ?>
<?php if (!empty($pairings)): ?>
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>اللاعب الأبيض</th>
<th>النتيجة</th> <th>النتيجة</th>
<th>اللاعب الأسود</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($pairings as $idx => $pairing): ?> <?php foreach ($results as $idx => $r): ?>
<tr> <tr>
<td><?= $idx + 1 ?></td> <td><?= $idx + 1 ?></td>
<td><?= View::e($pairing['white']['name'] ?? $pairing['white']['id'] ?? '-') ?></td> <td><span class="badge badge-default"><?= $r['result'] ?? '-' ?></span></td>
<td class="text-center font-medium">
<?= $resultsMap[$idx] ?? '-' ?>
</td>
<td><?= View::e($pairing['black']['name'] ?? $pairing['black']['id'] ?? '-') ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
<?php endif; ?> <?php endif; ?>
</div>
<?php if ($round['status'] !== 'completed' && $tournament['status'] === 'in_progress'): ?> <?php if ($round['status'] !== 'completed' && $tournament['status'] === 'in_progress'): ?>
<div class="round-actions mt-4"> <div class="round-actions p-4 pt-0">
<button type="button" class="btn btn-primary btn-sm" onclick="openResultsForm('<?= $round['id'] ?>', <?= htmlspecialchars(json_encode($pairings), ENT_QUOTES) ?>)"> <button type="button" class="btn btn-primary btn-sm" onclick="openResultsForm('<?= $round['id'] ?>', '<?= $round['swiss_round_id'] ?? '' ?>')">
إدخال النتائج إدخال النتائج
</button> </button>
</div> </div>
...@@ -270,11 +340,11 @@ $formatLabels = [ ...@@ -270,11 +340,11 @@ $formatLabels = [
<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> <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> </button>
</div> </div>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/submit-results" id="resultsForm"> <form method="POST" id="resultsForm" action="">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="round_id" id="resultsRoundId" value=""> <input type="hidden" name="round_id" id="resultsRoundId" value="">
<div class="modal-body" id="resultsBody"> <div class="modal-body" id="resultsBody">
<!-- Filled dynamically by JS --> <div class="text-center text-muted p-4">جاري تحميل المواجهات...</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeResultsModal()">إلغاء</button> <button type="button" class="btn btn-ghost" onclick="closeResultsModal()">إلغاء</button>
...@@ -288,14 +358,23 @@ $formatLabels = [ ...@@ -288,14 +358,23 @@ $formatLabels = [
<!-- Tab: Standings --> <!-- Tab: Standings -->
<?php if ($tab === 'standings'): ?> <?php if ($tab === 'standings'): ?>
<div class="card"> <div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="card-title">ترتيب اللاعبين</h3>
<?php if ($tournament['status'] === 'in_progress'): ?>
<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-sm btn-ghost">إعادة حساب</button>
</form>
<?php endif; ?>
</div>
<?php if (empty($standings)): ?> <?php if (empty($standings)): ?>
<div class="empty-state"> <div class="empty-state p-8">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5C7 4 6 9 6 9z"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5C17 4 18 9 18 9z"/><path d="M4 22h16"/><path d="M10 22V8a2 2 0 0 1 4 0v14"/></svg> <svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5C7 4 6 9 6 9z"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5C17 4 18 9 18 9z"/><path d="M4 22h16"/><path d="M10 22V8a2 2 0 0 1 4 0v14"/></svg>
<h3 class="empty-state-title">لا يوجد ترتيب</h3> <h3 class="empty-state-title">لا يوجد ترتيب</h3>
<p class="empty-state-text">سيظهر الترتيب بعد بدء البطولة وإنشاء الجولات</p> <p class="empty-state-text">سيظهر الترتيب بعد بدء البطولة وإنشاء الجولات</p>
</div> </div>
<?php else: ?> <?php else: ?>
<h3 class="card-title">ترتيب اللاعبين</h3>
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
...@@ -331,3 +410,80 @@ $formatLabels = [ ...@@ -331,3 +410,80 @@ $formatLabels = [
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Tab: Phases -->
<?php if ($tab === 'phases' && $isMultiPhase): ?>
<div class="phases-section">
<?php include __DIR__ . '/_phase_timeline.php'; ?>
<?php if ($currentPhaseDetails): ?>
<div class="card mt-4">
<h3 class="card-title">
المرحلة الحالية: <?= View::e($currentPhaseDetails['phase']['name_ar'] ?? $currentPhaseDetails['phase']['name'] ?? '') ?>
<span class="badge badge-warning">جارية</span>
</h3>
<div class="info-list p-4">
<div class="info-item">
<span class="info-label">النوع</span>
<span class="info-value"><?= $formatLabels[$currentPhaseDetails['phase']['type'] ?? ''] ?? '' ?></span>
</div>
<div class="info-item">
<span class="info-label">التقدم</span>
<span class="info-value">
<div class="progress-bar">
<div class="progress-fill" style="width: <?= $currentPhaseDetails['progress'] ?? 0 ?>%"></div>
</div>
<span class="text-xs text-muted"><?= $currentPhaseDetails['completed_matches'] ?? 0 ?> / <?= $currentPhaseDetails['total_matches'] ?? 0 ?> مباراة</span>
</span>
</div>
</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Tab: Bracket -->
<?php if ($tab === 'bracket'): ?>
<?php
$bracketEngine = new BracketEngine();
$bracketStates = [];
foreach ($brackets as $b) {
$bracketStates[] = $bracketEngine->getBracketState($b['id']);
}
$groupBrackets = array_filter($brackets, fn($b) => $b['bracket_type'] === 'group');
$elimBrackets = array_filter($brackets, fn($b) => $b['bracket_type'] !== 'group');
?>
<?php if (!empty($groupBrackets)): ?>
<?php include __DIR__ . '/_group_stage.php'; ?>
<?php endif; ?>
<?php if (!empty($elimBrackets)): ?>
<?php
$elimStates = [];
foreach ($elimBrackets as $b) {
$elimStates[] = $bracketEngine->getBracketState($b['id']);
}
$bracketData = $elimStates;
?>
<?php include __DIR__ . '/_bracket.php'; ?>
<?php endif; ?>
<?php if (empty($brackets)): ?>
<div class="empty-state">
<h3 class="empty-state-title">لا توجد قوسية</h3>
<p class="empty-state-text">ستظهر القوسية عند بدء مرحلة الإقصاء</p>
</div>
<?php endif; ?>
<?php endif; ?>
<!-- Tab: Arena -->
<?php if ($tab === 'arena'): ?>
<?php include __DIR__ . '/_arena_board.php'; ?>
<?php endif; ?>
<!-- Tab: Arbiter Tools -->
<?php if ($tab === 'arbiter'): ?>
<?php include __DIR__ . '/_arbiter_tools.php'; ?>
<?php endif; ?>
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