Primera version parea produccion

This commit is contained in:
Chelo I7 2026-05-28 00:52:21 -03:00
parent 833b4e198b
commit 6f9ccd5fc6
65 changed files with 5875 additions and 0 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
.git
.gitignore
.env
.env.*
*.log
docker-compose.override.yml
node_modules
vendor
storage/logs/*
public/uploads/*
torneos.code-workspace

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
.env
.env.*
*.log
storage/logs/*
public/uploads/*

13
Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM php:8.3-apache
RUN docker-php-ext-install pdo_mysql sockets
RUN a2enmod rewrite
WORKDIR /var/www/html
COPY . /var/www/html
COPY docker/apache.conf /etc/apache2/sites-available/000-default.conf
RUN mkdir -p /var/www/html/storage/logs /var/www/html/public/uploads \
&& chown -R www-data:www-data /var/www/html/storage /var/www/html/public/uploads
EXPOSE 80

View File

@ -0,0 +1,44 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Database;
use App\Core\Jwt;
use App\Core\Request;
use App\Core\Response;
final class AuthController
{
public function login(): void
{
$data = Request::json();
$stmt = Database::connection()->prepare('SELECT * FROM users WHERE email = :email AND active = 1');
$stmt->execute(['email' => $data['email'] ?? '']);
$user = $stmt->fetch();
if (!$user || !password_verify($data['password'] ?? '', $user['password_hash'])) {
Response::error('Credenciales inválidas', 422);
return;
}
$token = Jwt::encode([
'sub' => (int) $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
]);
Response::json(['token' => $token, 'user' => ['name' => $user['name'], 'email' => $user['email'], 'role' => $user['role']]]);
}
public function me(): void
{
$user = Auth::requireRole(['admin', 'delegate', 'public']);
Response::json(['user' => [
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
]]);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
final class CatalogController
{
public function courts(): void
{
Response::json(Database::connection()->query('SELECT * FROM courts ORDER BY name')->fetchAll());
}
public function referees(): void
{
Response::json(Database::connection()->query('SELECT * FROM referees ORDER BY name')->fetchAll());
}
public function sanction(array $params): void
{
Auth::requireRole(['admin', 'delegate']);
$data = Request::json();
$stmt = Database::connection()->prepare(
'INSERT INTO sanctions (match_id, team_id, player_id, card_type, reason)
VALUES (:match_id, :team_id, :player_id, :card_type, :reason)'
);
$stmt->execute([
'match_id' => (int) $params['id'],
'team_id' => $data['team_id'] ?? null,
'player_id' => $data['player_id'] ?? null,
'card_type' => $data['card_type'] ?? 'yellow',
'reason' => $data['reason'] ?? null,
]);
Response::json(['created' => true], 201);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Controllers;
use App\Services\ScoreSheetService;
final class ExportController
{
public function standingsCsv(array $params): void
{
$rows = (new ScoreSheetService())->standings((int) $params['id']);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="tabla-posiciones.csv"');
$out = fopen('php://output', 'w');
fputcsv($out, ['Equipo', 'PJ', 'G', 'P', 'SF', 'SC', 'Puntos']);
foreach ($rows as $row) {
fputcsv($out, [$row['name'], $row['played'], $row['won'], $row['lost'], $row['sets_for'], $row['sets_against'], $row['points']]);
}
}
public function standingsPdf(array $params): void
{
$rows = (new ScoreSheetService())->standings((int) $params['id']);
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html><title>Tabla de posiciones</title><style>body{font-family:Arial}table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:8px}</style>';
echo '<h1>Tabla de posiciones</h1><p>Usar imprimir / guardar como PDF desde el navegador.</p><table><tr><th>Equipo</th><th>PJ</th><th>G</th><th>P</th><th>SF</th><th>SC</th><th>Pts</th></tr>';
foreach ($rows as $row) {
echo sprintf('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>',
htmlspecialchars($row['name']), $row['played'], $row['won'], $row['lost'], $row['sets_for'], $row['sets_against'], $row['points']);
}
echo '</table>';
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Request;
use App\Core\Response;
use App\Core\Validator;
use App\Repositories\MatchRepository;
use App\Services\AdvancedScoreSheetService;
use App\Services\FixtureService;
use App\Services\ScoreSheetService;
final class MatchController
{
public function index(): void
{
Response::json((new MatchRepository())->all());
}
public function store(): void
{
Auth::requireRole(['admin']);
$data = Request::json();
$errors = Validator::require($data, ['tournament_id', 'home_team_id', 'away_team_id']);
if ($errors) {
Response::error('Datos inválidos', 422, $errors);
return;
}
Response::json((new MatchRepository())->create($data), 201);
}
public function generateFixture(array $params): void
{
Auth::requireRole(['admin']);
Response::json((new FixtureService())->generateLeague((int) $params['id'], Request::json()['start_date'] ?? null), 201);
}
public function scoreState(array $params): void
{
Response::json((new ScoreSheetService())->state((int) $params['id']));
}
public function scoreEvent(array $params): void
{
$user = Auth::requireRole(['admin', 'delegate']);
try {
Response::json((new ScoreSheetService())->addEvent((int) $params['id'], Request::json(), $user), 201);
} catch (\InvalidArgumentException $e) {
Response::error($e->getMessage(), 422);
}
}
public function advancedState(array $params): void
{
Response::json((new AdvancedScoreSheetService())->state((int) $params['id']));
}
public function rotation(array $params): void
{
$this->advanced($params, 'setRotation');
}
public function libero(array $params): void
{
$this->advanced($params, 'setLibero');
}
public function substitution(array $params): void
{
$this->advanced($params, 'substitute');
}
public function timeout(array $params): void
{
$this->advanced($params, 'timeout');
}
public function rally(array $params): void
{
$this->advanced($params, 'rally');
}
public function advancedSanction(array $params): void
{
$this->advanced($params, 'sanction');
}
public function signature(array $params): void
{
$this->advanced($params, 'sign');
}
private function advanced(array $params, string $method): void
{
$user = Auth::requireRole(['admin', 'delegate']);
try {
Response::json((new AdvancedScoreSheetService())->$method((int) $params['id'], Request::json(), $user), 201);
} catch (\InvalidArgumentException $e) {
Response::error($e->getMessage(), 422);
}
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
final class MatchSheetController
{
public function index(array $params): void
{
Auth::requireRole(['admin', 'delegate']);
Response::json($this->details((int) $params['id']));
}
public function save(array $params): void
{
Auth::requireRole(['admin', 'delegate']);
$matchId = (int) $params['id'];
$data = Request::json();
$db = Database::connection();
$stmt = $db->prepare(
'INSERT INTO match_sheet_details (match_id, team_id, captain_player_id, coach_name, observations)
VALUES (:match_id, :team_id, :captain_player_id, :coach_name, :observations)
ON DUPLICATE KEY UPDATE captain_player_id = VALUES(captain_player_id), coach_name = VALUES(coach_name), observations = VALUES(observations)'
);
$stmt->execute([
'match_id' => $matchId,
'team_id' => $data['team_id'] ?? null,
'captain_player_id' => $data['captain_player_id'] ?: null,
'coach_name' => $data['coach_name'] ?? null,
'observations' => $data['observations'] ?? null,
]);
Response::json($this->details($matchId), 201);
}
private function details(int $matchId): array
{
$stmt = Database::connection()->prepare(
'SELECT d.*, t.name team_name, CONCAT(p.first_name, " ", p.last_name) captain_name
FROM match_sheet_details d
LEFT JOIN teams t ON t.id = d.team_id
LEFT JOIN players p ON p.id = d.captain_player_id
WHERE d.match_id = :id
ORDER BY d.team_id'
);
$stmt->execute(['id' => $matchId]);
return $stmt->fetchAll();
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Request;
use App\Core\Response;
use App\Core\Validator;
use App\Repositories\PlayerRepository;
use App\Repositories\TeamRepository;
final class PlayerController
{
public function index(): void
{
Response::json((new PlayerRepository())->all());
}
public function store(): void
{
Auth::requireRole(['admin', 'delegate']);
$this->createFromData(Request::json());
}
public function registerByLink(array $params): void
{
$team = (new TeamRepository())->findByToken($params['token']);
if (!$team) {
Response::error('Link de equipo inválido', 404);
return;
}
$data = Request::json();
$data['team_id'] = $team['id'];
$this->createFromData($data);
}
private function createFromData(array $data): void
{
$errors = Validator::require($data, ['team_id', 'first_name', 'last_name', 'document_id']);
if ($errors) {
Response::error('Datos inválidos', 422, $errors);
return;
}
Response::json((new PlayerRepository())->create($data), 201);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Controllers;
use App\Core\Response;
use App\Services\ScoreSheetService;
final class PublicController
{
public function standings(array $params): void
{
Response::json((new ScoreSheetService())->standings((int) $params['id']));
}
public function stats(array $params): void
{
Response::json((new ScoreSheetService())->stats((int) $params['id']));
}
}

View File

@ -0,0 +1,159 @@
<?php
namespace App\Controllers;
use App\Core\Database;
use App\Repositories\SheetTemplateRepository;
final class ScoresheetExportController
{
public function ltv26(array $params): void
{
$matchId = (int) $params['id'];
$db = Database::connection();
$templateRow = (new SheetTemplateRepository())->resolveForMatch($matchId);
if (!$templateRow) {
$template = require __DIR__ . '/../../config/templates/ltv26_scoresheet.php';
} else {
$template = json_decode($templateRow['config_json'], true);
$template = is_array($template) ? $template : [];
$template['image'] = $templateRow['image_path'];
$template['width'] = (int) $templateRow['page_width'];
$template['height'] = (int) $templateRow['page_height'];
}
$match = $this->match($db, $matchId);
$sets = $this->rows($db, 'SELECT * FROM match_sets WHERE match_id = :id ORDER BY set_number', $matchId);
$homePlayers = $this->players($db, (int) $match['home_team_id']);
$awayPlayers = $this->players($db, (int) $match['away_team_id']);
$signatures = $this->rows($db, 'SELECT * FROM referee_signatures WHERE match_id = :id ORDER BY signed_at DESC', $matchId);
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
if ($templateRow) {
$payload = json_encode(['match_id' => $matchId, 'template_id' => $templateRow['id'], 'time' => time()]);
$db->prepare(
'INSERT INTO match_sheet_exports (match_id, sheet_template_id, export_type, export_hash)
VALUES (:match_id, :sheet_template_id, "html", :export_hash)'
)->execute([
'match_id' => $matchId,
'sheet_template_id' => $templateRow['id'],
'export_hash' => hash('sha256', $payload),
]);
}
echo $this->html($template, $match, $sets, $homePlayers, $awayPlayers, $signatures);
}
private function html(array $template, array $match, array $sets, array $homePlayers, array $awayPlayers, array $signatures): string
{
$fields = [
'tournament' => $match['tournament_name'],
'match_code' => '#' . $match['id'],
'date' => $match['scheduled_at'] ? date('d/m/Y', strtotime($match['scheduled_at'])) : '',
'time' => $match['scheduled_at'] ? date('H:i', strtotime($match['scheduled_at'])) : '',
'court' => $match['court_name'] ?? '',
'home_team' => $match['home_team'],
'away_team' => $match['away_team'],
'home_sets' => (string) $match['home_sets'],
'away_sets' => (string) $match['away_sets'],
'referee_signature' => $signatures[0]['signer_name'] ?? '',
];
$items = [];
foreach ($fields as $key => $value) {
if (isset($template['fields'][$key])) {
$items[] = $this->box($template, $template['fields'][$key], $value);
}
}
foreach ($sets as $index => $set) {
if (!isset($template['result_rows'][$index], $template['result_columns'])) {
continue;
}
$row = $template['result_rows'][$index];
$cols = $template['result_columns'];
$items[] = $this->box($template, ['x' => $cols['home_pt']['x'], 'y' => $row['y'], 'w' => $cols['home_pt']['w'], 'size' => 11, 'align' => 'center'], (string) $set['home_points']);
$items[] = $this->box($template, ['x' => $cols['set']['x'], 'y' => $row['y'], 'w' => $cols['set']['w'], 'size' => 11, 'align' => 'center'], $set['home_points'] . ' - ' . $set['away_points']);
$items[] = $this->box($template, ['x' => $cols['away_pt']['x'], 'y' => $row['y'], 'w' => $cols['away_pt']['w'], 'size' => 11, 'align' => 'center'], (string) $set['away_points']);
}
if (!empty($template['home_players'])) {
$items = array_merge($items, $this->playerBoxes($template, $template['home_players'], $homePlayers));
}
if (!empty($template['away_players'])) {
$items = array_merge($items, $this->playerBoxes($template, $template['away_players'], $awayPlayers));
}
$image = htmlspecialchars($template['image'], ENT_QUOTES);
return '<!doctype html><html><head><meta charset="utf-8"><title>Planilla LTV 26</title>'
. '<style>@page{size:landscape;margin:0}body{margin:0;background:#e5e7eb;padding:16px}.sheet{position:relative;width:min(100%,'
. (int) $template['width'] . 'px);margin:0 auto;background:#fff;box-shadow:0 18px 60px rgba(15,23,42,.25)}.sheet-img{display:block;width:100%;height:auto}.txt{position:absolute;font-family:Arial,sans-serif;font-weight:700;color:#111;white-space:nowrap;overflow:hidden;line-height:1.1}.toolbar{position:fixed;right:16px;top:16px;z-index:3}.toolbar button{border:0;border-radius:6px;background:#16a34a;color:white;font-weight:800;padding:10px 14px}.missing{padding:24px;color:#991b1b;font-family:Arial;font-weight:700}@media print{body{background:#fff;padding:0}.sheet{width:100vw;max-width:none;box-shadow:none}.toolbar{display:none}}</style>'
. '</head><body><div class="toolbar"><button onclick="window.print()">Imprimir / PDF</button></div><main class="sheet">'
. '<img class="sheet-img" src="' . $image . '" alt="Planilla" onerror="this.insertAdjacentHTML(\'afterend\', \'<div class=&quot;missing&quot;>No se pudo cargar la imagen de plantilla: ' . $image . '</div>\'); this.remove();">'
. implode('', $items)
. '</main></body></html>';
}
private function box(array $template, array $box, string $value): string
{
$align = $box['align'] ?? 'left';
$valign = $box['valign'] ?? 'top';
$translate = match ($valign) {
'middle' => 'translateY(-50%)',
'bottom' => 'translateY(-100%)',
default => 'none',
};
$style = sprintf(
'left:%s%%;top:%s%%;width:%s%%;font-size:%spx;text-align:%s;transform:%s',
($box['x'] / $template['width']) * 100,
($box['y'] / $template['height']) * 100,
($box['w'] / $template['width']) * 100,
$box['size'],
$align,
$translate
);
return '<span class="txt" style="' . $style . '">' . htmlspecialchars($value) . '</span>';
}
private function playerBoxes(array $template, array $config, array $players): array
{
foreach (['x', 'y', 'row_h', 'number_w', 'name_w', 'size'] as $required) {
if (!isset($config[$required])) {
return [];
}
}
$items = [];
foreach (array_slice($players, 0, 14) as $index => $player) {
$y = $config['y'] + ($index * $config['row_h']);
$items[] = $this->box($template, ['x' => $config['x'], 'y' => $y, 'w' => $config['number_w'], 'size' => $config['size'], 'align' => 'center'], (string) ($player['jersey_number'] ?? ''));
$items[] = $this->box($template, ['x' => $config['x'] + $config['number_w'], 'y' => $y, 'w' => $config['name_w'], 'size' => $config['size']], $player['first_name'] . ' ' . $player['last_name']);
}
return $items;
}
private function match(\PDO $db, int $matchId): array
{
$stmt = $db->prepare(
'SELECT m.*, tr.name tournament_name, ht.name home_team, at.name away_team, c.name court_name
FROM matches m
JOIN tournaments tr ON tr.id = m.tournament_id
JOIN teams ht ON ht.id = m.home_team_id
JOIN teams at ON at.id = m.away_team_id
LEFT JOIN courts c ON c.id = m.court_id
WHERE m.id = :id'
);
$stmt->execute(['id' => $matchId]);
return $stmt->fetch() ?: [];
}
private function players(\PDO $db, int $teamId): array
{
$stmt = $db->prepare('SELECT * FROM players WHERE team_id = :id ORDER BY jersey_number, last_name');
$stmt->execute(['id' => $teamId]);
return $stmt->fetchAll();
}
private function rows(\PDO $db, string $sql, int $matchId): array
{
$stmt = $db->prepare($sql);
$stmt->execute(['id' => $matchId]);
return $stmt->fetchAll();
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Request;
use App\Core\Response;
use App\Core\Validator;
use App\Repositories\SheetTemplateRepository;
final class SheetTemplateController
{
public function index(): void
{
Auth::requireRole(['admin']);
Response::json((new SheetTemplateRepository())->all());
}
public function store(): void
{
Auth::requireRole(['admin']);
$data = Request::json();
$errors = Validator::require($data, ['code', 'name', 'image_path', 'page_width', 'page_height', 'config_json']);
if ($errors) {
Response::error('Datos invalidos', 422, $errors);
return;
}
Response::json((new SheetTemplateRepository())->create($data), 201);
}
public function show(array $params): void
{
Auth::requireRole(['admin']);
$template = (new SheetTemplateRepository())->find((int) $params['id']);
Response::json($template ?: ['message' => 'No encontrado'], $template ? 200 : 404);
}
public function effective(array $params): void
{
Auth::requireRole(['admin']);
$repo = new SheetTemplateRepository();
$template = $repo->find((int) $params['id']);
if (!$template) {
Response::error('No encontrado', 404);
return;
}
$template['effective_config_json'] = json_encode($repo->effectiveConfig($template), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
Response::json($template);
}
public function update(array $params): void
{
Auth::requireRole(['admin']);
$template = (new SheetTemplateRepository())->update((int) $params['id'], Request::json());
Response::json($template ?: ['message' => 'No encontrado'], $template ? 200 : 404);
}
public function assignTournament(array $params): void
{
Auth::requireRole(['admin']);
(new SheetTemplateRepository())->assignToTournament((int) $params['id'], (int) Request::json()['sheet_template_id']);
Response::json(['assigned' => true]);
}
public function overrideMatch(array $params): void
{
Auth::requireRole(['admin']);
(new SheetTemplateRepository())->overrideMatch((int) $params['id'], (int) Request::json()['sheet_template_id']);
Response::json(['assigned' => true]);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Request;
use App\Core\Response;
use App\Core\Validator;
use App\Repositories\TeamRepository;
final class TeamController
{
public function index(): void
{
Response::json((new TeamRepository())->all());
}
public function store(): void
{
Auth::requireRole(['admin']);
$data = Request::json();
$errors = Validator::require($data, ['tournament_id', 'name']);
if ($errors) {
Response::error('Datos inválidos', 422, $errors);
return;
}
Response::json((new TeamRepository())->create($data), 201);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Request;
use App\Core\Response;
use App\Core\Validator;
use App\Repositories\TournamentRepository;
use App\Services\DemoScoresheetSeeder;
final class TournamentController
{
public function index(): void
{
Response::json((new TournamentRepository())->all());
}
public function store(): void
{
Auth::requireRole(['admin']);
$data = Request::json();
$errors = Validator::require($data, ['name', 'category', 'format']);
if ($errors) {
Response::error('Datos inválidos', 422, $errors);
return;
}
Response::json((new TournamentRepository())->create($data), 201);
}
public function demoScoresheet(array $params): void
{
Auth::requireRole(['admin']);
$data = Request::json();
Response::json((new DemoScoresheetSeeder())->seed((int) $params['id'], (bool) ($data['force'] ?? false)), 201);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Response;
final class UploadController
{
public function image(): void
{
Auth::requireRole(['admin', 'delegate']);
if (empty($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
Response::error('Imagen inválida', 422);
return;
}
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
$mime = mime_content_type($_FILES['image']['tmp_name']);
if (!isset($allowed[$mime])) {
Response::error('Formato no permitido', 422);
return;
}
$name = bin2hex(random_bytes(12)) . '.' . $allowed[$mime];
$target = __DIR__ . '/../../public/uploads/' . $name;
move_uploaded_file($_FILES['image']['tmp_name'], $target);
Response::json(['path' => '/uploads/' . $name], 201);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Request;
use App\Core\Response;
use App\Core\Validator;
use App\Repositories\UserRepository;
final class UserController
{
public function index(): void
{
Auth::requireRole(['admin']);
Response::json((new UserRepository())->all());
}
public function store(): void
{
Auth::requireRole(['admin']);
$data = Request::json();
$errors = Validator::require($data, ['name', 'email', 'password', 'role']);
if ($errors) {
Response::error('Datos inválidos', 422, $errors);
return;
}
Response::json((new UserRepository())->create($data), 201);
}
public function update(array $params): void
{
Auth::requireRole(['admin']);
$user = (new UserRepository())->update((int) $params['id'], Request::json());
if (!$user) {
Response::error('Usuario no encontrado', 404);
return;
}
Response::json($user);
}
}

28
app/Core/Auth.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Core;
final class Auth
{
public static function user(): ?array
{
$token = Request::bearerToken();
return $token ? Jwt::decode($token) : null;
}
public static function requireRole(array $roles): array
{
$user = self::user();
if (!$user) {
Response::error('No autenticado', 401);
exit;
}
if (!in_array($user['role'], $roles, true)) {
Response::error('No autorizado', 403);
exit;
}
return $user;
}
}

35
app/Core/Database.php Normal file
View File

@ -0,0 +1,35 @@
<?php
namespace App\Core;
use PDO;
final class Database
{
private static ?PDO $pdo = null;
public static function connection(): PDO
{
if (self::$pdo) {
return self::$pdo;
}
$config = require __DIR__ . '/../../config/app.php';
$db = $config['db'];
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
$db['host'],
$db['port'],
$db['database'],
$db['charset']
);
self::$pdo = new PDO($dsn, $db['username'], $db['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return self::$pdo;
}
}

55
app/Core/Jwt.php Normal file
View File

@ -0,0 +1,55 @@
<?php
namespace App\Core;
final class Jwt
{
public static function encode(array $payload): string
{
$config = require __DIR__ . '/../../config/app.php';
$header = ['typ' => 'JWT', 'alg' => 'HS256'];
$payload['iat'] = time();
$payload['exp'] = time() + $config['jwt_ttl'];
$segments = [
self::base64Url(json_encode($header)),
self::base64Url(json_encode($payload)),
];
$signature = hash_hmac('sha256', implode('.', $segments), $config['jwt_secret'], true);
$segments[] = self::base64Url($signature);
return implode('.', $segments);
}
public static function decode(string $token): ?array
{
$config = require __DIR__ . '/../../config/app.php';
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$header, $payload, $signature] = $parts;
$expected = self::base64Url(hash_hmac('sha256', "$header.$payload", $config['jwt_secret'], true));
if (!hash_equals($expected, $signature)) {
return null;
}
$data = json_decode(self::base64UrlDecode($payload), true);
if (!is_array($data) || ($data['exp'] ?? 0) < time()) {
return null;
}
return $data;
}
private static function base64Url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $value): string
{
return base64_decode(strtr($value, '-_', '+/')) ?: '';
}
}

12
app/Core/Log.php Normal file
View File

@ -0,0 +1,12 @@
<?php
namespace App\Core;
final class Log
{
public static function error(string $message, array $context = []): void
{
$line = sprintf("[%s] %s %s\n", date('c'), $message, json_encode($context));
file_put_contents(__DIR__ . '/../../storage/logs/app.log', $line, FILE_APPEND);
}
}

40
app/Core/Request.php Normal file
View File

@ -0,0 +1,40 @@
<?php
namespace App\Core;
final class Request
{
public static function json(): array
{
$body = file_get_contents('php://input') ?: '';
if ($body === '') {
return [];
}
$data = json_decode($body, true);
return is_array($data) ? $data : [];
}
public static function query(string $key, mixed $default = null): mixed
{
return $_GET[$key] ?? $default;
}
public static function bearerToken(): ?string
{
$header = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
if ($header === '' && function_exists('apache_request_headers')) {
$headers = apache_request_headers();
$header = $headers['Authorization'] ?? $headers['authorization'] ?? '';
}
if (str_starts_with($header, 'Bearer ')) {
return substr($header, 7);
}
return null;
}
}

18
app/Core/Response.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Core;
final class Response
{
public static function json(mixed $data, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
public static function error(string $message, int $status = 400, array $errors = []): void
{
self::json(['message' => $message, 'errors' => $errors], $status);
}
}

31
app/Core/Router.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Core;
final class Router
{
private array $routes = [];
public function add(string $method, string $path, array $handler): void
{
$pattern = '#^' . preg_replace('#\{([a-zA-Z_]+)\}#', '(?P<$1>[^/]+)', $path) . '$#';
$this->routes[] = [$method, $pattern, $handler];
}
public function dispatch(string $method, string $uri): void
{
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
foreach ($this->routes as [$routeMethod, $pattern, $handler]) {
if ($routeMethod !== $method || !preg_match($pattern, $path, $matches)) {
continue;
}
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
[$class, $action] = $handler;
(new $class())->$action($params);
return;
}
Response::error('Ruta no encontrada', 404);
}
}

18
app/Core/Validator.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Core;
final class Validator
{
public static function require(array $data, array $fields): array
{
$errors = [];
foreach ($fields as $field) {
if (!isset($data[$field]) || $data[$field] === '') {
$errors[$field] = 'Campo obligatorio';
}
}
return $errors;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Repositories;
use App\Core\Database;
use PDO;
abstract class BaseRepository
{
protected PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
protected function paginate(string $sql, array $params = []): array
{
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = min(100, max(5, (int) ($_GET['per_page'] ?? 20)));
$offset = ($page - 1) * $perPage;
$stmt = $this->db->prepare($sql . " LIMIT $perPage OFFSET $offset");
$stmt->execute($params);
return [
'data' => $stmt->fetchAll(),
'meta' => ['page' => $page, 'per_page' => $perPage],
];
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Repositories;
final class MatchRepository extends BaseRepository
{
public function all(): array
{
$params = [];
$sql = 'SELECT m.*, ht.name AS home_team, at.name AS away_team, c.name AS court_name
FROM matches m
JOIN teams ht ON ht.id = m.home_team_id
JOIN teams at ON at.id = m.away_team_id
LEFT JOIN courts c ON c.id = m.court_id';
if (!empty($_GET['tournament_id'])) {
$sql .= ' WHERE m.tournament_id = :tournament_id';
$params['tournament_id'] = (int) $_GET['tournament_id'];
}
$sql .= ' ORDER BY m.scheduled_at ASC, m.id ASC';
return $this->paginate($sql, $params);
}
public function create(array $data): array
{
$stmt = $this->db->prepare(
'INSERT INTO matches (tournament_id, phase, scheduled_at, court_id, home_team_id, away_team_id, status)
VALUES (:tournament_id, :phase, :scheduled_at, :court_id, :home_team_id, :away_team_id, :status)'
);
$stmt->execute([
'tournament_id' => $data['tournament_id'],
'phase' => $data['phase'] ?? 'regular',
'scheduled_at' => $data['scheduled_at'] ?? null,
'court_id' => $data['court_id'] ?? null,
'home_team_id' => $data['home_team_id'],
'away_team_id' => $data['away_team_id'],
'status' => $data['status'] ?? 'scheduled',
]);
return $this->find((int) $this->db->lastInsertId());
}
public function find(int $id): ?array
{
$stmt = $this->db->prepare(
'SELECT m.*, ht.name AS home_team, at.name AS away_team
FROM matches m
JOIN teams ht ON ht.id = m.home_team_id
JOIN teams at ON at.id = m.away_team_id
WHERE m.id = :id'
);
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Repositories;
final class PlayerRepository extends BaseRepository
{
public function all(): array
{
$params = [];
$sql = 'SELECT players.*, teams.name AS team_name
FROM players JOIN teams ON teams.id = players.team_id';
if (!empty($_GET['team_id'])) {
$sql .= ' WHERE team_id = :team_id';
$params['team_id'] = (int) $_GET['team_id'];
}
$sql .= ' ORDER BY players.last_name, players.first_name';
return $this->paginate($sql, $params);
}
public function create(array $data): array
{
$stmt = $this->db->prepare(
'INSERT INTO players (team_id, first_name, last_name, document_id, birth_date, jersey_number, position, photo_path)
VALUES (:team_id, :first_name, :last_name, :document_id, :birth_date, :jersey_number, :position, :photo_path)'
);
$stmt->execute([
'team_id' => $data['team_id'],
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'document_id' => $data['document_id'],
'birth_date' => $data['birth_date'] ?? null,
'jersey_number' => $data['jersey_number'] ?? null,
'position' => $data['position'] ?? null,
'photo_path' => $data['photo_path'] ?? null,
]);
$id = (int) $this->db->lastInsertId();
$stmt = $this->db->prepare('SELECT * FROM players WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch();
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Repositories;
final class SheetTemplateRepository extends BaseRepository
{
public function all(): array
{
$tournamentId = isset($_GET['tournament_id']) ? (int) $_GET['tournament_id'] : 0;
if ($tournamentId > 0) {
return $this->paginate(
'SELECT st.*, CASE WHEN tst.sheet_template_id IS NULL THEN 0 ELSE 1 END AS assigned_to_tournament
FROM sheet_templates st
LEFT JOIN tournament_sheet_templates tst
ON tst.sheet_template_id = st.id AND tst.tournament_id = :tournament_id
ORDER BY assigned_to_tournament DESC, st.active DESC, st.name ASC',
['tournament_id' => $tournamentId]
);
}
return $this->paginate('SELECT *, 0 AS assigned_to_tournament FROM sheet_templates ORDER BY active DESC, name ASC');
}
public function create(array $data): array
{
$stmt = $this->db->prepare(
'INSERT INTO sheet_templates (code, name, image_path, page_width, page_height, config_json, active)
VALUES (:code, :name, :image_path, :page_width, :page_height, :config_json, :active)'
);
$stmt->execute([
'code' => $data['code'],
'name' => $data['name'],
'image_path' => $data['image_path'],
'page_width' => (int) $data['page_width'],
'page_height' => (int) $data['page_height'],
'config_json' => is_string($data['config_json']) ? $data['config_json'] : json_encode($data['config_json']),
'active' => (int) ($data['active'] ?? 1),
]);
return $this->find((int) $this->db->lastInsertId());
}
public function update(int $id, array $data): ?array
{
$allowed = ['code', 'name', 'image_path', 'page_width', 'page_height', 'config_json', 'active'];
$fields = [];
$params = ['id' => $id];
foreach ($allowed as $field) {
if (array_key_exists($field, $data)) {
$fields[] = "$field = :$field";
$params[$field] = $field === 'config_json' && !is_string($data[$field]) ? json_encode($data[$field]) : $data[$field];
}
}
if ($fields) {
$this->db->prepare('UPDATE sheet_templates SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
}
return $this->find($id);
}
public function assignToTournament(int $tournamentId, int $templateId): void
{
$this->db->prepare('DELETE FROM tournament_sheet_templates WHERE tournament_id = :id')->execute(['id' => $tournamentId]);
$this->db->prepare(
'INSERT INTO tournament_sheet_templates (tournament_id, sheet_template_id, is_default)
VALUES (:tournament_id, :sheet_template_id, 1)'
)->execute(['tournament_id' => $tournamentId, 'sheet_template_id' => $templateId]);
}
public function overrideMatch(int $matchId, int $templateId): void
{
$this->db->prepare(
'INSERT INTO match_sheet_template_overrides (match_id, sheet_template_id)
VALUES (:match_id, :sheet_template_id)
ON DUPLICATE KEY UPDATE sheet_template_id = VALUES(sheet_template_id)'
)->execute(['match_id' => $matchId, 'sheet_template_id' => $templateId]);
}
public function find(int $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM sheet_templates WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
public function resolveForMatch(int $matchId): ?array
{
$stmt = $this->db->prepare(
'SELECT st.*
FROM matches m
LEFT JOIN match_sheet_template_overrides mo ON mo.match_id = m.id
LEFT JOIN tournament_sheet_templates tt ON tt.tournament_id = m.tournament_id AND tt.is_default = 1
JOIN sheet_templates st ON st.id = COALESCE(mo.sheet_template_id, tt.sheet_template_id)
WHERE m.id = :id AND st.active = 1
LIMIT 1'
);
$stmt->execute(['id' => $matchId]);
$template = $stmt->fetch();
if ($template) {
return $template;
}
$stmt = $this->db->query('SELECT * FROM sheet_templates WHERE active = 1 ORDER BY id LIMIT 1');
return $stmt->fetch() ?: null;
}
public function effectiveConfig(array $template): array
{
$config = json_decode($template['config_json'] ?? '{}', true);
return is_array($config) ? $config : [];
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Repositories;
final class TeamRepository extends BaseRepository
{
public function all(): array
{
$params = [];
$sql = 'SELECT teams.*, tournaments.name AS tournament_name
FROM teams JOIN tournaments ON tournaments.id = teams.tournament_id';
if (!empty($_GET['tournament_id'])) {
$sql .= ' WHERE teams.tournament_id = :tournament_id';
$params['tournament_id'] = (int) $_GET['tournament_id'];
}
$sql .= ' ORDER BY teams.name';
return $this->paginate($sql, $params);
}
public function create(array $data): array
{
$token = bin2hex(random_bytes(16));
$stmt = $this->db->prepare(
'INSERT INTO teams (tournament_id, name, logo_path, coach_name, delegate_user_id, registration_token)
VALUES (:tournament_id, :name, :logo_path, :coach_name, :delegate_user_id, :registration_token)'
);
$stmt->execute([
'tournament_id' => $data['tournament_id'],
'name' => $data['name'],
'logo_path' => $data['logo_path'] ?? null,
'coach_name' => $data['coach_name'] ?? null,
'delegate_user_id' => $data['delegate_user_id'] ?? null,
'registration_token' => $token,
]);
return $this->find((int) $this->db->lastInsertId());
}
public function find(int $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM teams WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
public function findByToken(string $token): ?array
{
$stmt = $this->db->prepare('SELECT * FROM teams WHERE registration_token = :token');
$stmt->execute(['token' => $token]);
return $stmt->fetch() ?: null;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Repositories;
final class TournamentRepository extends BaseRepository
{
public function all(): array
{
$where = [];
$params = [];
if (!empty($_GET['category'])) {
$where[] = 'category = :category';
$params['category'] = $_GET['category'];
}
if (!empty($_GET['status'])) {
$where[] = 'status = :status';
$params['status'] = $_GET['status'];
}
$sql = 'SELECT * FROM tournaments';
if ($where) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY starts_at DESC, id DESC';
return $this->paginate($sql, $params);
}
public function create(array $data): array
{
$stmt = $this->db->prepare(
'INSERT INTO tournaments (name, category, age_subcategory, format, status, starts_at, ends_at)
VALUES (:name, :category, :age_subcategory, :format, :status, :starts_at, :ends_at)'
);
$stmt->execute([
'name' => $data['name'],
'category' => $data['category'],
'age_subcategory' => $data['age_subcategory'] ?? null,
'format' => $data['format'],
'status' => $data['status'] ?? 'draft',
'starts_at' => $data['starts_at'] ?? null,
'ends_at' => $data['ends_at'] ?? null,
]);
return $this->find((int) $this->db->lastInsertId());
}
public function find(int $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM tournaments WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Repositories;
final class UserRepository extends BaseRepository
{
public function all(): array
{
$params = [];
$sql = 'SELECT id, name, email, role, active, created_at FROM users';
if (!empty($_GET['role'])) {
$sql .= ' WHERE role = :role';
$params['role'] = $_GET['role'];
}
$sql .= ' ORDER BY created_at DESC, id DESC';
return $this->paginate($sql, $params);
}
public function create(array $data): array
{
$stmt = $this->db->prepare(
'INSERT INTO users (name, email, password_hash, role, active)
VALUES (:name, :email, :password_hash, :role, :active)'
);
$stmt->execute([
'name' => $data['name'],
'email' => $data['email'],
'password_hash' => password_hash($data['password'], PASSWORD_BCRYPT),
'role' => $data['role'],
'active' => (int) ($data['active'] ?? 1),
]);
return $this->find((int) $this->db->lastInsertId());
}
public function update(int $id, array $data): ?array
{
$fields = [];
$params = ['id' => $id];
foreach (['name', 'email', 'role', 'active'] as $field) {
if (array_key_exists($field, $data)) {
$fields[] = "$field = :$field";
$params[$field] = $field === 'active' ? (int) $data[$field] : $data[$field];
}
}
if (!empty($data['password'])) {
$fields[] = 'password_hash = :password_hash';
$params['password_hash'] = password_hash($data['password'], PASSWORD_BCRYPT);
}
if (!$fields) {
return $this->find($id);
}
$stmt = $this->db->prepare('UPDATE users SET ' . implode(', ', $fields) . ' WHERE id = :id');
$stmt->execute($params);
return $this->find($id);
}
public function find(int $id): ?array
{
$stmt = $this->db->prepare('SELECT id, name, email, role, active, created_at FROM users WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
}

View File

@ -0,0 +1,332 @@
<?php
namespace App\Services;
use App\Core\Database;
use InvalidArgumentException;
use PDO;
final class AdvancedScoreSheetService
{
private PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
public function state(int $matchId): array
{
return [
'rotations' => $this->rows('SELECT r.*, CONCAT(p.first_name, " ", p.last_name) player_name, t.name team_name
FROM rotations r JOIN players p ON p.id = r.player_id JOIN teams t ON t.id = r.team_id
WHERE r.match_id = :id ORDER BY r.set_number, r.team_id, r.position_number', $matchId),
'liberos' => $this->rows('SELECT l.*, CONCAT(p.first_name, " ", p.last_name) player_name, t.name team_name
FROM match_liberos l JOIN players p ON p.id = l.player_id JOIN teams t ON t.id = l.team_id
WHERE l.match_id = :id ORDER BY l.set_number, l.team_id', $matchId),
'substitutions' => $this->rows('SELECT s.*, outp.first_name out_first, outp.last_name out_last, inp.first_name in_first, inp.last_name in_last, t.name team_name
FROM substitutions s JOIN players outp ON outp.id = s.player_out_id JOIN players inp ON inp.id = s.player_in_id JOIN teams t ON t.id = s.team_id
WHERE s.match_id = :id ORDER BY s.id DESC', $matchId),
'timeouts' => $this->rows('SELECT to1.*, t.name team_name FROM timeouts to1 JOIN teams t ON t.id = to1.team_id
WHERE to1.match_id = :id ORDER BY to1.id DESC', $matchId),
'rallies' => $this->rows('SELECT r.*, wt.name winning_team, st.name serving_team
FROM rally_history r LEFT JOIN teams wt ON wt.id = r.winning_team_id LEFT JOIN teams st ON st.id = r.serving_team_id
WHERE r.match_id = :id ORDER BY r.id DESC LIMIT 100', $matchId),
'audit' => $this->rows('SELECT a.*, u.name user_name FROM referee_audit_logs a LEFT JOIN users u ON u.id = a.user_id
WHERE a.match_id = :id ORDER BY a.id DESC LIMIT 100', $matchId),
'signatures' => $this->rows('SELECT * FROM referee_signatures WHERE match_id = :id ORDER BY signed_at DESC', $matchId),
];
}
public function setRotation(int $matchId, array $data, array $user): array
{
$set = $this->currentSet($matchId);
$teamId = $this->validTeam($matchId, (int) $data['team_id']);
$position = (int) $data['position_number'];
if ($position < 1 || $position > 6) {
throw new InvalidArgumentException('La posicion debe estar entre 1 y 6');
}
$this->ensurePlayerInTeam((int) $data['player_id'], $teamId);
$stmt = $this->db->prepare(
'INSERT INTO rotations (match_id, team_id, set_number, position_number, player_id, is_libero)
VALUES (:match_id, :team_id, :set_number, :position_number, :player_id, :is_libero)
ON DUPLICATE KEY UPDATE player_id = VALUES(player_id), is_libero = VALUES(is_libero)'
);
$stmt->execute([
'match_id' => $matchId,
'team_id' => $teamId,
'set_number' => $data['set_number'] ?? $set['set_number'],
'position_number' => $position,
'player_id' => (int) $data['player_id'],
'is_libero' => (int) ($data['is_libero'] ?? 0),
]);
$this->event($matchId, 'rotation', $teamId, $data['player_id'], "Rotacion posicion $position", $user);
$this->audit($matchId, 'rotation.set', $data, $user);
return $this->state($matchId);
}
public function setLibero(int $matchId, array $data, array $user): array
{
$set = $this->currentSet($matchId);
$teamId = $this->validTeam($matchId, (int) $data['team_id']);
$this->ensurePlayerInTeam((int) $data['player_id'], $teamId);
$stmt = $this->db->prepare(
'INSERT IGNORE INTO match_liberos (match_id, team_id, player_id, set_number, is_starting)
VALUES (:match_id, :team_id, :player_id, :set_number, :is_starting)'
);
$stmt->execute([
'match_id' => $matchId,
'team_id' => $teamId,
'player_id' => (int) $data['player_id'],
'set_number' => $data['set_number'] ?? $set['set_number'],
'is_starting' => (int) ($data['is_starting'] ?? 0),
]);
$this->event($matchId, 'libero', $teamId, $data['player_id'], 'Registro de libero', $user);
$this->audit($matchId, 'libero.set', $data, $user);
return $this->state($matchId);
}
public function substitute(int $matchId, array $data, array $user): array
{
$set = $this->currentSet($matchId);
$teamId = $this->validTeam($matchId, (int) $data['team_id']);
$setNumber = (int) ($data['set_number'] ?? $set['set_number']);
$this->ensurePlayerInTeam((int) $data['player_out_id'], $teamId);
$this->ensurePlayerInTeam((int) $data['player_in_id'], $teamId);
if ((int) $data['player_out_id'] === (int) $data['player_in_id']) {
throw new InvalidArgumentException('Los jugadores de entrada y salida deben ser distintos');
}
if ($this->countRows('substitutions', $matchId, $teamId, $setNumber) >= 6) {
throw new InvalidArgumentException('Maximo reglamentario de 6 sustituciones por set');
}
$stmt = $this->db->prepare(
'INSERT INTO substitutions (match_id, team_id, set_number, player_out_id, player_in_id, reason, created_by)
VALUES (:match_id, :team_id, :set_number, :player_out_id, :player_in_id, :reason, :created_by)'
);
$stmt->execute([
'match_id' => $matchId,
'team_id' => $teamId,
'set_number' => $setNumber,
'player_out_id' => (int) $data['player_out_id'],
'player_in_id' => (int) $data['player_in_id'],
'reason' => $data['reason'] ?? null,
'created_by' => $user['sub'] ?? null,
]);
$this->event($matchId, 'substitution', $teamId, $data['player_in_id'], 'Sustitucion reglamentaria', $user);
$this->audit($matchId, 'substitution.create', $data, $user);
return $this->state($matchId);
}
public function timeout(int $matchId, array $data, array $user): array
{
$set = $this->currentSet($matchId);
$teamId = $this->validTeam($matchId, (int) $data['team_id']);
$setNumber = (int) ($data['set_number'] ?? $set['set_number']);
if ($this->countRows('timeouts', $matchId, $teamId, $setNumber) >= 2) {
throw new InvalidArgumentException('Maximo reglamentario de 2 tiempos por set');
}
$stmt = $this->db->prepare(
'INSERT INTO timeouts (match_id, team_id, set_number, points_home, points_away, requested_by, created_by)
VALUES (:match_id, :team_id, :set_number, :points_home, :points_away, :requested_by, :created_by)'
);
$stmt->execute([
'match_id' => $matchId,
'team_id' => $teamId,
'set_number' => $setNumber,
'points_home' => $set['home_points'],
'points_away' => $set['away_points'],
'requested_by' => $data['requested_by'] ?? null,
'created_by' => $user['sub'] ?? null,
]);
$this->event($matchId, 'timeout', $teamId, null, 'Tiempo solicitado', $user);
$this->audit($matchId, 'timeout.create', $data, $user);
return $this->state($matchId);
}
public function rally(int $matchId, array $data, array $user): array
{
$set = $this->currentSet($matchId);
$winningTeamId = isset($data['winning_team_id']) ? $this->validTeam($matchId, (int) $data['winning_team_id']) : null;
$servingTeamId = isset($data['serving_team_id']) ? $this->validTeam($matchId, (int) $data['serving_team_id']) : null;
$rallyNumber = $this->nextRallyNumber($matchId, (int) $set['set_number']);
$stmt = $this->db->prepare(
'INSERT INTO rally_history
(match_id, set_number, rally_number, serving_team_id, winning_team_id, result_type, points_home, points_away, notes, created_by)
VALUES (:match_id, :set_number, :rally_number, :serving_team_id, :winning_team_id, :result_type, :points_home, :points_away, :notes, :created_by)'
);
$stmt->execute([
'match_id' => $matchId,
'set_number' => $set['set_number'],
'rally_number' => $rallyNumber,
'serving_team_id' => $servingTeamId,
'winning_team_id' => $winningTeamId,
'result_type' => $data['result_type'] ?? 'point',
'points_home' => $set['home_points'],
'points_away' => $set['away_points'],
'notes' => $data['notes'] ?? null,
'created_by' => $user['sub'] ?? null,
]);
$this->audit($matchId, 'rally.create', $data, $user);
return $this->state($matchId);
}
public function sanction(int $matchId, array $data, array $user): array
{
$teamId = !empty($data['team_id']) ? $this->validTeam($matchId, (int) $data['team_id']) : null;
$playerId = !empty($data['player_id']) ? (int) $data['player_id'] : null;
$card = $data['card_type'] ?? 'yellow';
if (!in_array($card, ['yellow', 'red'], true)) {
throw new InvalidArgumentException('Tarjeta invalida');
}
$stmt = $this->db->prepare(
'INSERT INTO sanctions (match_id, team_id, player_id, card_type, reason)
VALUES (:match_id, :team_id, :player_id, :card_type, :reason)'
);
$stmt->execute([
'match_id' => $matchId,
'team_id' => $teamId,
'player_id' => $playerId,
'card_type' => $card,
'reason' => $data['reason'] ?? null,
]);
$this->event($matchId, $card === 'red' ? 'red_card' : 'yellow_card', $teamId, $playerId, $data['reason'] ?? 'Sancion', $user);
$this->audit($matchId, 'sanction.create', $data, $user);
return $this->state($matchId);
}
public function sign(int $matchId, array $data, array $user): array
{
$payload = [
'match_id' => $matchId,
'signer_name' => $data['signer_name'],
'role' => $data['role'] ?? 'principal',
'user_id' => $user['sub'] ?? null,
'signed_at' => date('c'),
];
$hash = hash('sha256', json_encode($payload));
$stmt = $this->db->prepare(
'INSERT INTO referee_signatures (match_id, referee_id, signer_name, role, signature_hash, signed_payload)
VALUES (:match_id, :referee_id, :signer_name, :role, :signature_hash, :signed_payload)
ON DUPLICATE KEY UPDATE signer_name = VALUES(signer_name), signature_hash = VALUES(signature_hash), signed_payload = VALUES(signed_payload), signed_at = CURRENT_TIMESTAMP'
);
$stmt->execute([
'match_id' => $matchId,
'referee_id' => $data['referee_id'] ?? null,
'signer_name' => $data['signer_name'],
'role' => $payload['role'],
'signature_hash' => $hash,
'signed_payload' => json_encode($payload),
]);
$this->event($matchId, 'signature', null, null, 'Firma digital arbitral', $user);
$this->audit($matchId, 'signature.create', ['hash' => $hash] + $data, $user);
return ['signature_hash' => $hash, 'advanced' => $this->state($matchId)];
}
private function event(int $matchId, string $type, ?int $teamId, mixed $playerId, string $notes, array $user): void
{
$set = $this->currentSet($matchId);
$stmt = $this->db->prepare(
'INSERT INTO match_events (match_id, set_number, team_id, player_id, event_type, points_home, points_away, notes, created_by)
VALUES (:match_id, :set_number, :team_id, :player_id, :event_type, :points_home, :points_away, :notes, :created_by)'
);
$stmt->execute([
'match_id' => $matchId,
'set_number' => $set['set_number'],
'team_id' => $teamId,
'player_id' => $playerId ?: null,
'event_type' => $type,
'points_home' => $set['home_points'],
'points_away' => $set['away_points'],
'notes' => $notes,
'created_by' => $user['sub'] ?? null,
]);
}
private function audit(int $matchId, string $action, array $payload, array $user): void
{
$stmt = $this->db->prepare('INSERT INTO referee_audit_logs (match_id, user_id, action, payload) VALUES (:match_id, :user_id, :action, :payload)');
$stmt->execute([
'match_id' => $matchId,
'user_id' => $user['sub'] ?? null,
'action' => $action,
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
}
private function validTeam(int $matchId, int $teamId): int
{
$match = $this->match($matchId);
if (!in_array($teamId, [(int) $match['home_team_id'], (int) $match['away_team_id']], true)) {
throw new InvalidArgumentException('El equipo no participa en este partido');
}
return $teamId;
}
private function ensurePlayerInTeam(int $playerId, int $teamId): void
{
$stmt = $this->db->prepare('SELECT id FROM players WHERE id = :id AND team_id = :team_id');
$stmt->execute(['id' => $playerId, 'team_id' => $teamId]);
if (!$stmt->fetch()) {
throw new InvalidArgumentException('El jugador no pertenece al equipo');
}
}
private function currentSet(int $matchId): array
{
$stmt = $this->db->prepare('SELECT * FROM match_sets WHERE match_id = :id AND winner_team_id IS NULL ORDER BY set_number LIMIT 1');
$stmt->execute(['id' => $matchId]);
$set = $stmt->fetch();
if ($set) {
return $set;
}
$this->db->prepare('INSERT INTO match_sets (match_id, set_number) VALUES (:id, 1)')->execute(['id' => $matchId]);
$stmt = $this->db->prepare('SELECT * FROM match_sets WHERE id = :id');
$stmt->execute(['id' => $this->db->lastInsertId()]);
return $stmt->fetch();
}
private function match(int $matchId): array
{
$stmt = $this->db->prepare('SELECT * FROM matches WHERE id = :id');
$stmt->execute(['id' => $matchId]);
$match = $stmt->fetch();
if (!$match) {
throw new InvalidArgumentException('Partido no encontrado');
}
return $match;
}
private function countRows(string $table, int $matchId, int $teamId, int $setNumber): int
{
$stmt = $this->db->prepare("SELECT COUNT(*) total FROM $table WHERE match_id = :match_id AND team_id = :team_id AND set_number = :set_number");
$stmt->execute(['match_id' => $matchId, 'team_id' => $teamId, 'set_number' => $setNumber]);
return (int) $stmt->fetch()['total'];
}
private function nextRallyNumber(int $matchId, int $setNumber): int
{
$stmt = $this->db->prepare('SELECT COALESCE(MAX(rally_number), 0) + 1 next_number FROM rally_history WHERE match_id = :match_id AND set_number = :set_number');
$stmt->execute(['match_id' => $matchId, 'set_number' => $setNumber]);
return (int) $stmt->fetch()['next_number'];
}
private function rows(string $sql, int $matchId): array
{
$stmt = $this->db->prepare($sql);
$stmt->execute(['id' => $matchId]);
return $stmt->fetchAll();
}
}

View File

@ -0,0 +1,191 @@
<?php
namespace App\Services;
use App\Core\Database;
use PDO;
final class DemoScoresheetSeeder
{
private PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
public function seed(int $tournamentId, bool $force = false): array
{
if (!$force && $this->hasDemo($tournamentId)) {
return ['created' => false, 'message' => 'El torneo ya tiene demo de planilla'];
}
$this->db->beginTransaction();
try {
$homeTeamId = $this->team($tournamentId, 'Demo A', 'DT Demo A');
$awayTeamId = $this->team($tournamentId, 'Demo B', 'DT Demo B');
$homePlayers = $this->players($homeTeamId, 'A');
$awayPlayers = $this->players($awayTeamId, 'B');
$matchId = $this->match($tournamentId, $homeTeamId, $awayTeamId);
$this->sets($matchId, $homeTeamId);
$this->sheetDetails($matchId, $homeTeamId, $homePlayers[0], 'DT Demo A', 'Observaciones demo equipo A');
$this->sheetDetails($matchId, $awayTeamId, $awayPlayers[0], 'DT Demo B', 'Observaciones demo equipo B');
$this->libero($matchId, $homeTeamId, $homePlayers[11]);
$this->libero($matchId, $awayTeamId, $awayPlayers[11]);
$this->rotations($matchId, $homeTeamId, array_slice($homePlayers, 0, 6));
$this->rotations($matchId, $awayTeamId, array_slice($awayPlayers, 0, 6));
$this->signature($matchId);
$this->events($matchId, $homeTeamId, $awayTeamId);
$this->db->commit();
return ['created' => true, 'match_id' => $matchId, 'home_team_id' => $homeTeamId, 'away_team_id' => $awayTeamId];
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
private function hasDemo(int $tournamentId): bool
{
$stmt = $this->db->prepare('SELECT id FROM teams WHERE tournament_id = :id AND name IN ("Demo A", "Demo B") LIMIT 1');
$stmt->execute(['id' => $tournamentId]);
return (bool) $stmt->fetch();
}
private function team(int $tournamentId, string $name, string $coach): int
{
$stmt = $this->db->prepare('SELECT id FROM teams WHERE tournament_id = :tournament_id AND name = :name');
$stmt->execute(['tournament_id' => $tournamentId, 'name' => $name]);
$row = $stmt->fetch();
if ($row) {
return (int) $row['id'];
}
$stmt = $this->db->prepare(
'INSERT INTO teams (tournament_id, name, coach_name, registration_token)
VALUES (:tournament_id, :name, :coach_name, :registration_token)'
);
$stmt->execute([
'tournament_id' => $tournamentId,
'name' => $name,
'coach_name' => $coach,
'registration_token' => bin2hex(random_bytes(16)),
]);
return (int) $this->db->lastInsertId();
}
private function players(int $teamId, string $prefix): array
{
$ids = [];
$positions = ['Armador', 'Opuesto', 'Central', 'Punta', 'Punta', 'Central', 'Universal', 'Universal', 'Punta', 'Central', 'Opuesto', 'Libero'];
for ($i = 1; $i <= 12; $i++) {
$doc = "DEMO-$prefix-$i";
$stmt = $this->db->prepare('SELECT id FROM players WHERE team_id = :team_id AND document_id = :doc');
$stmt->execute(['team_id' => $teamId, 'doc' => $doc]);
$row = $stmt->fetch();
if ($row) {
$ids[] = (int) $row['id'];
continue;
}
$stmt = $this->db->prepare(
'INSERT INTO players (team_id, first_name, last_name, document_id, birth_date, jersey_number, position)
VALUES (:team_id, :first_name, :last_name, :document_id, "1998-01-01", :jersey_number, :position)'
);
$stmt->execute([
'team_id' => $teamId,
'first_name' => "Jugador$prefix",
'last_name' => str_pad((string) $i, 2, '0', STR_PAD_LEFT),
'document_id' => $doc,
'jersey_number' => $i,
'position' => $positions[$i - 1],
]);
$ids[] = (int) $this->db->lastInsertId();
}
return $ids;
}
private function match(int $tournamentId, int $homeTeamId, int $awayTeamId): int
{
$stmt = $this->db->prepare('SELECT id FROM matches WHERE tournament_id = :tournament_id AND home_team_id = :home AND away_team_id = :away LIMIT 1');
$stmt->execute(['tournament_id' => $tournamentId, 'home' => $homeTeamId, 'away' => $awayTeamId]);
$row = $stmt->fetch();
if ($row) {
return (int) $row['id'];
}
$stmt = $this->db->prepare(
'INSERT INTO matches (tournament_id, phase, scheduled_at, home_team_id, away_team_id, status, winner_team_id, home_sets, away_sets)
VALUES (:tournament_id, "demo", NOW(), :home, :away, "finished", :winner, 3, 1)'
);
$stmt->execute(['tournament_id' => $tournamentId, 'home' => $homeTeamId, 'away' => $awayTeamId, 'winner' => $homeTeamId]);
return (int) $this->db->lastInsertId();
}
private function sets(int $matchId, int $winnerTeamId): void
{
$scores = [[25, 21], [22, 25], [25, 18], [25, 23]];
foreach ($scores as $index => [$home, $away]) {
$stmt = $this->db->prepare(
'INSERT INTO match_sets (match_id, set_number, home_points, away_points, winner_team_id)
VALUES (:match_id, :set_number, :home, :away, :winner)
ON DUPLICATE KEY UPDATE home_points = VALUES(home_points), away_points = VALUES(away_points), winner_team_id = VALUES(winner_team_id)'
);
$stmt->execute([
'match_id' => $matchId,
'set_number' => $index + 1,
'home' => $home,
'away' => $away,
'winner' => $home > $away ? $winnerTeamId : null,
]);
}
}
private function sheetDetails(int $matchId, int $teamId, int $captainId, string $coach, string $observations): void
{
$stmt = $this->db->prepare(
'INSERT INTO match_sheet_details (match_id, team_id, captain_player_id, coach_name, observations)
VALUES (:match_id, :team_id, :captain, :coach, :observations)
ON DUPLICATE KEY UPDATE captain_player_id = VALUES(captain_player_id), coach_name = VALUES(coach_name), observations = VALUES(observations)'
);
$stmt->execute(['match_id' => $matchId, 'team_id' => $teamId, 'captain' => $captainId, 'coach' => $coach, 'observations' => $observations]);
}
private function libero(int $matchId, int $teamId, int $playerId): void
{
$this->db->prepare(
'INSERT IGNORE INTO match_liberos (match_id, team_id, player_id, set_number, is_starting)
VALUES (:match_id, :team_id, :player_id, 1, 1)'
)->execute(['match_id' => $matchId, 'team_id' => $teamId, 'player_id' => $playerId]);
}
private function rotations(int $matchId, int $teamId, array $players): void
{
foreach ($players as $index => $playerId) {
$this->db->prepare(
'INSERT INTO rotations (match_id, team_id, set_number, position_number, player_id)
VALUES (:match_id, :team_id, 1, :position, :player_id)
ON DUPLICATE KEY UPDATE player_id = VALUES(player_id)'
)->execute(['match_id' => $matchId, 'team_id' => $teamId, 'position' => $index + 1, 'player_id' => $playerId]);
}
}
private function signature(int $matchId): void
{
$payload = ['match_id' => $matchId, 'signer_name' => 'Arbitro Demo', 'role' => 'principal'];
$this->db->prepare(
'INSERT INTO referee_signatures (match_id, signer_name, role, signature_hash, signed_payload)
VALUES (:match_id, "Arbitro Demo", "principal", :hash, :payload)
ON DUPLICATE KEY UPDATE signature_hash = VALUES(signature_hash), signed_payload = VALUES(signed_payload)'
)->execute(['match_id' => $matchId, 'hash' => hash('sha256', json_encode($payload)), 'payload' => json_encode($payload)]);
}
private function events(int $matchId, int $homeTeamId, int $awayTeamId): void
{
foreach ([['point', $homeTeamId, 1, 25, 21], ['timeout', $awayTeamId, 1, 18, 16], ['yellow_card', $awayTeamId, 2, 10, 9]] as [$type, $teamId, $set, $home, $away]) {
$this->db->prepare(
'INSERT INTO match_events (match_id, set_number, team_id, event_type, points_home, points_away, notes)
VALUES (:match_id, :set_number, :team_id, :event_type, :home, :away, "Demo planilla")'
)->execute(['match_id' => $matchId, 'set_number' => $set, 'team_id' => $teamId, 'event_type' => $type, 'home' => $home, 'away' => $away]);
}
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Services;
use App\Core\Database;
use PDO;
final class FixtureService
{
private PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
public function generateLeague(int $tournamentId, ?string $startDate = null): array
{
$teams = $this->teams($tournamentId);
$created = [];
$date = new \DateTimeImmutable($startDate ?: 'next saturday 18:00');
for ($i = 0; $i < count($teams); $i++) {
for ($j = $i + 1; $j < count($teams); $j++) {
$stmt = $this->db->prepare(
'INSERT INTO matches (tournament_id, phase, scheduled_at, home_team_id, away_team_id, status)
VALUES (:tournament_id, "regular", :scheduled_at, :home_team_id, :away_team_id, "scheduled")'
);
$stmt->execute([
'tournament_id' => $tournamentId,
'scheduled_at' => $date->format('Y-m-d H:i:s'),
'home_team_id' => $teams[$i]['id'],
'away_team_id' => $teams[$j]['id'],
]);
$created[] = (int) $this->db->lastInsertId();
$date = $date->modify('+2 hours');
}
}
return ['created' => count($created), 'match_ids' => $created];
}
private function teams(int $tournamentId): array
{
$stmt = $this->db->prepare('SELECT id, name FROM teams WHERE tournament_id = :id ORDER BY id');
$stmt->execute(['id' => $tournamentId]);
return $stmt->fetchAll();
}
}

View File

@ -0,0 +1,261 @@
<?php
namespace App\Services;
use App\Core\Database;
use InvalidArgumentException;
use PDO;
final class ScoreSheetService
{
private PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
public function state(int $matchId): array
{
$match = $this->match($matchId);
$sets = $this->sets($matchId);
$events = $this->events($matchId);
return [
'match' => $match,
'sets' => $sets,
'sets_won' => [
'home' => count(array_filter($sets, fn ($set) => ($set['winner_team_id'] ?? null) === $match['home_team_id'])),
'away' => count(array_filter($sets, fn ($set) => ($set['winner_team_id'] ?? null) === $match['away_team_id'])),
],
'events' => $events,
];
}
public function addEvent(int $matchId, array $data, array $user): array
{
$match = $this->match($matchId);
if (($match['status'] ?? '') === 'finished') {
throw new InvalidArgumentException('El partido ya finalizó');
}
$type = $data['event_type'] ?? 'point';
$teamId = (int) ($data['team_id'] ?? 0);
if ($teamId && !in_array($teamId, [(int) $match['home_team_id'], (int) $match['away_team_id']], true)) {
throw new InvalidArgumentException('El equipo no participa en este partido');
}
$this->db->beginTransaction();
try {
$set = $this->currentSet($matchId);
if ($type === 'point') {
$homePoint = $teamId === (int) $match['home_team_id'] ? 1 : 0;
$awayPoint = $teamId === (int) $match['away_team_id'] ? 1 : 0;
$stmt = $this->db->prepare(
'UPDATE match_sets
SET home_points = home_points + :home, away_points = away_points + :away
WHERE id = :id'
);
$stmt->execute(['home' => $homePoint, 'away' => $awayPoint, 'id' => $set['id']]);
$set = $this->setById((int) $set['id']);
}
$stmt = $this->db->prepare(
'INSERT INTO match_events
(match_id, set_number, team_id, player_id, event_type, points_home, points_away, notes, created_by)
VALUES (:match_id, :set_number, :team_id, :player_id, :event_type, :points_home, :points_away, :notes, :created_by)'
);
$stmt->execute([
'match_id' => $matchId,
'set_number' => $set['set_number'],
'team_id' => $teamId ?: null,
'player_id' => $data['player_id'] ?? null,
'event_type' => $type,
'points_home' => $set['home_points'],
'points_away' => $set['away_points'],
'notes' => $data['notes'] ?? null,
'created_by' => $user['sub'] ?? null,
]);
$this->evaluateSetAndMatch($matchId, $match, $set);
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
return $this->state($matchId);
}
public function standings(int $tournamentId): array
{
$stmt = $this->db->prepare(
'SELECT t.id, t.name,
COALESCE(s.played, 0) played,
COALESCE(s.won, 0) won,
COALESCE(s.lost, 0) lost,
COALESCE(s.sets_for, 0) sets_for,
COALESCE(s.sets_against, 0) sets_against,
COALESCE(s.points, 0) points
FROM teams t
LEFT JOIN team_standings s ON s.team_id = t.id
WHERE t.tournament_id = :tournament_id
ORDER BY points DESC, (sets_for - sets_against) DESC, sets_for DESC, t.name ASC'
);
$stmt->execute(['tournament_id' => $tournamentId]);
return $stmt->fetchAll();
}
public function stats(int $tournamentId): array
{
$stmt = $this->db->prepare(
'SELECT p.id, CONCAT(p.first_name, " ", p.last_name) AS player_name, tm.name AS team_name,
SUM(e.event_type = "point") AS points,
SUM(e.event_type = "ace") AS aces,
SUM(e.event_type = "block") AS blocks,
SUM(e.event_type = "attack") AS attacks,
SUM(e.event_type = "mvp") AS mvp
FROM players p
JOIN teams tm ON tm.id = p.team_id
LEFT JOIN match_events e ON e.player_id = p.id
WHERE tm.tournament_id = :tournament_id
GROUP BY p.id, player_name, team_name
ORDER BY points DESC, aces DESC, blocks DESC
LIMIT 50'
);
$stmt->execute(['tournament_id' => $tournamentId]);
return ['players' => $stmt->fetchAll(), 'teams' => $this->standings($tournamentId)];
}
private function evaluateSetAndMatch(int $matchId, array $match, array $set): void
{
$target = (int) $set['set_number'] === 5 ? 15 : 25;
$home = (int) $set['home_points'];
$away = (int) $set['away_points'];
if (max($home, $away) < $target || abs($home - $away) < 2) {
$this->markLive($matchId);
return;
}
$winner = $home > $away ? $match['home_team_id'] : $match['away_team_id'];
$stmt = $this->db->prepare('UPDATE match_sets SET winner_team_id = :winner WHERE id = :id');
$stmt->execute(['winner' => $winner, 'id' => $set['id']]);
$sets = $this->sets($matchId);
$homeSets = count(array_filter($sets, fn ($row) => ($row['winner_team_id'] ?? null) === $match['home_team_id']));
$awaySets = count(array_filter($sets, fn ($row) => ($row['winner_team_id'] ?? null) === $match['away_team_id']));
if ($homeSets === 3 || $awaySets === 3) {
$winnerTeam = $homeSets === 3 ? $match['home_team_id'] : $match['away_team_id'];
$this->finishMatch($matchId, (int) $winnerTeam, $homeSets, $awaySets);
return;
}
$nextNumber = (int) $set['set_number'] + 1;
$sideSwitch = $nextNumber % 2 === 0 ? 'switched' : 'normal';
$stmt = $this->db->prepare(
'INSERT IGNORE INTO match_sets (match_id, set_number, home_points, away_points, side_state)
VALUES (:match_id, :set_number, 0, 0, :side_state)'
);
$stmt->execute(['match_id' => $matchId, 'set_number' => $nextNumber, 'side_state' => $sideSwitch]);
}
private function finishMatch(int $matchId, int $winnerTeamId, int $homeSets, int $awaySets): void
{
$stmt = $this->db->prepare(
'UPDATE matches SET status = "finished", winner_team_id = :winner, home_sets = :home_sets, away_sets = :away_sets WHERE id = :id'
);
$stmt->execute(['winner' => $winnerTeamId, 'home_sets' => $homeSets, 'away_sets' => $awaySets, 'id' => $matchId]);
$this->rebuildStandings((int) $this->match($matchId)['tournament_id']);
}
public function rebuildStandings(int $tournamentId): void
{
$this->db->prepare('DELETE FROM team_standings WHERE tournament_id = :id')->execute(['id' => $tournamentId]);
$teams = $this->db->prepare('SELECT id FROM teams WHERE tournament_id = :id');
$teams->execute(['id' => $tournamentId]);
foreach ($teams->fetchAll() as $team) {
$stmt = $this->db->prepare(
'SELECT
SUM((home_team_id = :team OR away_team_id = :team) AND status = "finished") played,
SUM(winner_team_id = :team) won,
SUM(status = "finished" AND winner_team_id <> :team) lost,
SUM(CASE WHEN home_team_id = :team THEN home_sets WHEN away_team_id = :team THEN away_sets ELSE 0 END) sets_for,
SUM(CASE WHEN home_team_id = :team THEN away_sets WHEN away_team_id = :team THEN home_sets ELSE 0 END) sets_against
FROM matches WHERE tournament_id = :tournament_id'
);
$stmt->execute(['team' => $team['id'], 'tournament_id' => $tournamentId]);
$row = $stmt->fetch();
$won = (int) ($row['won'] ?? 0);
$lost = (int) ($row['lost'] ?? 0);
$this->db->prepare(
'INSERT INTO team_standings (tournament_id, team_id, played, won, lost, sets_for, sets_against, points)
VALUES (:tournament_id, :team_id, :played, :won, :lost, :sets_for, :sets_against, :points)'
)->execute([
'tournament_id' => $tournamentId,
'team_id' => $team['id'],
'played' => (int) ($row['played'] ?? 0),
'won' => $won,
'lost' => $lost,
'sets_for' => (int) ($row['sets_for'] ?? 0),
'sets_against' => (int) ($row['sets_against'] ?? 0),
'points' => ($won * 3) + $lost,
]);
}
}
private function currentSet(int $matchId): array
{
$stmt = $this->db->prepare('SELECT * FROM match_sets WHERE match_id = :id AND winner_team_id IS NULL ORDER BY set_number LIMIT 1');
$stmt->execute(['id' => $matchId]);
$set = $stmt->fetch();
if ($set) {
return $set;
}
$this->db->prepare('INSERT INTO match_sets (match_id, set_number) VALUES (:id, 1)')->execute(['id' => $matchId]);
return $this->setById((int) $this->db->lastInsertId());
}
private function markLive(int $matchId): void
{
$this->db->prepare('UPDATE matches SET status = "live" WHERE id = :id AND status = "scheduled"')->execute(['id' => $matchId]);
}
private function match(int $matchId): array
{
$stmt = $this->db->prepare('SELECT * FROM matches WHERE id = :id');
$stmt->execute(['id' => $matchId]);
$match = $stmt->fetch();
if (!$match) {
throw new InvalidArgumentException('Partido no encontrado');
}
return $match;
}
private function setById(int $id): array
{
$stmt = $this->db->prepare('SELECT * FROM match_sets WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch();
}
private function sets(int $matchId): array
{
$stmt = $this->db->prepare('SELECT * FROM match_sets WHERE match_id = :id ORDER BY set_number');
$stmt->execute(['id' => $matchId]);
return $stmt->fetchAll();
}
private function events(int $matchId): array
{
$stmt = $this->db->prepare(
'SELECT e.*, CONCAT(p.first_name, " ", p.last_name) AS player_name, t.name AS team_name
FROM match_events e
LEFT JOIN players p ON p.id = e.player_id
LEFT JOIN teams t ON t.id = e.team_id
WHERE e.match_id = :id
ORDER BY e.id DESC LIMIT 100'
);
$stmt->execute(['id' => $matchId]);
return $stmt->fetchAll();
}
}

88
bin/websocket-server.php Normal file
View File

@ -0,0 +1,88 @@
<?php
require __DIR__ . '/../app/Core/Database.php';
use App\Core\Database;
$server = stream_socket_server('tcp://0.0.0.0:8081', $errno, $errstr);
if (!$server) {
fwrite(STDERR, "WebSocket error: $errstr\n");
exit(1);
}
stream_set_blocking($server, false);
$clients = [];
$lastEventId = 0;
echo "WebSocket listening on 0.0.0.0:8081\n";
while (true) {
$read = array_merge([$server], array_column($clients, 'socket'));
$write = $except = [];
@stream_select($read, $write, $except, 1);
foreach ($read as $socket) {
if ($socket === $server) {
$client = stream_socket_accept($server, 0);
if ($client) {
stream_set_blocking($client, false);
$clients[(int) $client] = ['socket' => $client, 'handshake' => false];
}
continue;
}
$id = (int) $socket;
$buffer = fread($socket, 2048);
if ($buffer === '' || $buffer === false) {
fclose($socket);
unset($clients[$id]);
continue;
}
if (!$clients[$id]['handshake']) {
if (preg_match('/Sec-WebSocket-Key: (.*)\r\n/i', $buffer, $matches)) {
$key = trim($matches[1]);
$accept = base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
fwrite($socket, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: $accept\r\n\r\n");
$clients[$id]['handshake'] = true;
}
}
}
try {
$db = Database::connection();
$stmt = $db->prepare('SELECT id, match_id, event_type, points_home, points_away FROM match_events WHERE id > :id ORDER BY id ASC LIMIT 50');
$stmt->execute(['id' => $lastEventId]);
foreach ($stmt->fetchAll() as $event) {
$lastEventId = (int) $event['id'];
broadcast($clients, json_encode(['type' => 'match_event', 'data' => $event]));
}
} catch (Throwable $e) {
usleep(300000);
}
}
function broadcast(array &$clients, string $payload): void
{
$frame = frame($payload);
foreach ($clients as $id => $client) {
if (!$client['handshake']) {
continue;
}
if (@fwrite($client['socket'], $frame) === false) {
fclose($client['socket']);
unset($clients[$id]);
}
}
}
function frame(string $payload): string
{
$length = strlen($payload);
if ($length <= 125) {
return chr(129) . chr($length) . $payload;
}
if ($length <= 65535) {
return chr(129) . chr(126) . pack('n', $length) . $payload;
}
return chr(129) . chr(127) . pack('J', $length) . $payload;
}

18
config/app.php Normal file
View File

@ -0,0 +1,18 @@
<?php
return [
'app_name' => getenv('APP_NAME') ?: 'Volley Manager',
'env' => getenv('APP_ENV') ?: 'local',
'debug' => filter_var(getenv('APP_DEBUG') ?: true, FILTER_VALIDATE_BOOL),
'jwt_secret' => getenv('JWT_SECRET') ?: 'change-me-in-production',
'jwt_ttl' => (int) (getenv('JWT_TTL') ?: 86400),
'cors_origin' => getenv('CORS_ORIGIN') ?: '*',
'db' => [
'host' => getenv('DB_HOST') ?: '127.0.0.1',
'port' => getenv('DB_PORT') ?: '3306',
'database' => getenv('DB_DATABASE') ?: 'volley_tournaments',
'username' => getenv('DB_USERNAME') ?: 'volley',
'password' => getenv('DB_PASSWORD') ?: 'volley',
'charset' => 'utf8mb4',
],
];

View File

@ -0,0 +1,43 @@
<?php
return [
'image' => '/templates/planilla-ltv-26.png',
'width' => 1418,
'height' => 970,
'fields' => [
'tournament' => ['x' => 164, 'y' => 766, 'w' => 198, 'size' => 12, 'align' => 'center'],
'date' => ['x' => 164, 'y' => 798, 'w' => 105, 'size' => 11, 'align' => 'center'],
'court' => ['x' => 31, 'y' => 798, 'w' => 126, 'size' => 11, 'align' => 'center'],
'time' => ['x' => 276, 'y' => 798, 'w' => 84, 'size' => 11, 'align' => 'center'],
'home_team' => ['x' => 1218, 'y' => 500, 'w' => 74, 'size' => 10, 'align' => 'center'],
'away_team' => ['x' => 1308, 'y' => 500, 'w' => 76, 'size' => 10, 'align' => 'center'],
'home_sets' => ['x' => 911, 'y' => 728, 'w' => 26, 'size' => 16, 'align' => 'center'],
'away_sets' => ['x' => 962, 'y' => 728, 'w' => 26, 'size' => 16, 'align' => 'center'],
'winner' => ['x' => 865, 'y' => 923, 'w' => 190, 'size' => 13],
'referee_signature' => ['x' => 1238, 'y' => 858, 'w' => 146, 'size' => 10],
],
'result_rows' => [
['y' => 790],
['y' => 816],
['y' => 842],
['y' => 867],
['y' => 893],
],
'result_columns' => [
'home_t' => ['x' => 804, 'w' => 24],
'home_s' => ['x' => 831, 'w' => 26],
'home_g' => ['x' => 858, 'w' => 25],
'home_pt' => ['x' => 886, 'w' => 34],
'set' => ['x' => 921, 'w' => 70],
'away_pt' => ['x' => 992, 'w' => 34],
'away_g' => ['x' => 1028, 'w' => 24],
'away_s' => ['x' => 1054, 'w' => 24],
'away_t' => ['x' => 1078, 'w' => 24],
],
'home_players' => ['x' => 1081, 'y' => 548, 'row_h' => 22, 'number_w' => 32, 'name_w' => 124, 'size' => 10],
'away_players' => ['x' => 1238, 'y' => 548, 'row_h' => 22, 'number_w' => 32, 'name_w' => 124, 'size' => 10],
'libero' => [
'home' => ['x' => 1108, 'y' => 821, 'w' => 118, 'size' => 10],
'away' => ['x' => 1268, 'y' => 821, 'w' => 118, 'size' => 10],
],
];

16
database/fix-mojibake.sql Normal file
View File

@ -0,0 +1,16 @@
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
UPDATE teams SET name = 'Condores VC' WHERE name IN ('Cóndores VC', 'Cóndores VC');
UPDATE teams SET coach_name = 'Julian Paz' WHERE coach_name IN ('Julián Paz', 'Julián Paz');
UPDATE teams SET name = 'Central Volley' WHERE name IN ('Central Vóley', 'Central Vóley');
UPDATE teams SET name = 'Atletico Red' WHERE name IN ('Atlético Red', 'Atlético Red');
UPDATE teams SET coach_name = 'Ramiro Lopez' WHERE coach_name IN ('Ramiro López', 'Ramiro López');
UPDATE players SET first_name = 'Sofia' WHERE first_name IN ('Sofía', 'Sofía');
UPDATE players SET last_name = 'Benitez' WHERE last_name IN ('Benítez', 'Benítez');
UPDATE players SET first_name = 'Lucia' WHERE first_name IN ('Lucía', 'Lucía');
UPDATE players SET first_name = 'Tomas' WHERE first_name IN ('Tomás', 'Tomás');
UPDATE players SET last_name = 'Diaz' WHERE last_name IN ('­az', 'Díaz');
UPDATE players SET last_name = 'Gomez' WHERE last_name IN ('Gómez', 'Gómez');
UPDATE players SET first_name = 'Nicolas' WHERE first_name IN ('Nicolás', 'Nicolás');

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS match_sheet_details (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED,
captain_player_id BIGINT UNSIGNED,
coach_name VARCHAR(160),
observations TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_sheet_details_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_sheet_details_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_sheet_details_captain FOREIGN KEY (captain_player_id) REFERENCES players(id) ON DELETE SET NULL,
UNIQUE KEY uq_sheet_match_team (match_id, team_id)
) ENGINE=InnoDB;

View File

@ -0,0 +1,112 @@
ALTER TABLE match_events
MODIFY event_type ENUM('serve','point','error','ace','block','attack','rotation','substitution','timeout','libero','yellow_card','red_card','mvp','audit','signature') NOT NULL;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'rotations'
AND COLUMN_NAME = 'is_libero'
);
SET @ddl := IF(
@column_exists = 0,
'ALTER TABLE rotations ADD COLUMN is_libero BOOLEAN NOT NULL DEFAULT FALSE',
'SELECT "rotations.is_libero already exists"'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS match_liberos (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED NOT NULL,
player_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
is_starting BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_liberos_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_liberos_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_liberos_player FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE,
UNIQUE KEY uq_match_team_set_libero (match_id, team_id, set_number, player_id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS substitutions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
player_out_id BIGINT UNSIGNED NOT NULL,
player_in_id BIGINT UNSIGNED NOT NULL,
reason VARCHAR(120),
created_by BIGINT UNSIGNED,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_substitutions_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_substitutions_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_substitutions_out FOREIGN KEY (player_out_id) REFERENCES players(id),
CONSTRAINT fk_substitutions_in FOREIGN KEY (player_in_id) REFERENCES players(id),
CONSTRAINT fk_substitutions_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_substitutions_match_set (match_id, set_number, team_id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS timeouts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
points_home TINYINT UNSIGNED NOT NULL DEFAULT 0,
points_away TINYINT UNSIGNED NOT NULL DEFAULT 0,
requested_by VARCHAR(120),
created_by BIGINT UNSIGNED,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_timeouts_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_timeouts_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_timeouts_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_timeouts_match_set (match_id, set_number, team_id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS rally_history (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
rally_number INT UNSIGNED NOT NULL,
serving_team_id BIGINT UNSIGNED,
winning_team_id BIGINT UNSIGNED,
result_type ENUM('point','error','ace','block','attack') NOT NULL DEFAULT 'point',
points_home TINYINT UNSIGNED NOT NULL DEFAULT 0,
points_away TINYINT UNSIGNED NOT NULL DEFAULT 0,
notes VARCHAR(255),
created_by BIGINT UNSIGNED,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rally_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_rally_serving_team FOREIGN KEY (serving_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_rally_winning_team FOREIGN KEY (winning_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_rally_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_rally_number (match_id, set_number, rally_number)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS referee_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED,
action VARCHAR(80) NOT NULL,
payload JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_audit_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_audit_match_created (match_id, created_at)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS referee_signatures (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
referee_id BIGINT UNSIGNED,
signer_name VARCHAR(140) NOT NULL,
role VARCHAR(80) NOT NULL DEFAULT 'principal',
signature_hash CHAR(64) NOT NULL,
signed_payload JSON,
signed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_signatures_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_signatures_referee FOREIGN KEY (referee_id) REFERENCES referees(id) ON DELETE SET NULL,
UNIQUE KEY uq_signature_match_role (match_id, role)
) ENGINE=InnoDB;

View File

@ -0,0 +1,45 @@
CREATE TABLE IF NOT EXISTS sheet_templates (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(80) NOT NULL UNIQUE,
name VARCHAR(160) NOT NULL,
image_path VARCHAR(255) NOT NULL,
page_width INT UNSIGNED NOT NULL,
page_height INT UNSIGNED NOT NULL,
config_json JSON NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS tournament_sheet_templates (
tournament_id BIGINT UNSIGNED NOT NULL,
sheet_template_id BIGINT UNSIGNED NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (tournament_id, sheet_template_id),
CONSTRAINT fk_tournament_sheet_tournament FOREIGN KEY (tournament_id) REFERENCES tournaments(id) ON DELETE CASCADE,
CONSTRAINT fk_tournament_sheet_template FOREIGN KEY (sheet_template_id) REFERENCES sheet_templates(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS match_sheet_template_overrides (
match_id BIGINT UNSIGNED PRIMARY KEY,
sheet_template_id BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_match_sheet_override_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_match_sheet_override_template FOREIGN KEY (sheet_template_id) REFERENCES sheet_templates(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS match_sheet_exports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
sheet_template_id BIGINT UNSIGNED NOT NULL,
export_type ENUM('html','pdf','image') NOT NULL DEFAULT 'html',
file_path VARCHAR(255),
export_hash CHAR(64),
created_by BIGINT UNSIGNED,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_sheet_exports_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_sheet_exports_template FOREIGN KEY (sheet_template_id) REFERENCES sheet_templates(id),
CONSTRAINT fk_sheet_exports_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_sheet_exports_match (match_id, created_at)
) ENGINE=InnoDB;

271
database/schema.sql Normal file
View File

@ -0,0 +1,271 @@
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
email VARCHAR(160) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin','delegate','public') NOT NULL DEFAULT 'public',
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE tournaments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(160) NOT NULL,
category ENUM('Masculino','Femenino','Mixto') NOT NULL,
age_subcategory VARCHAR(80),
format ENUM('Liga','Eliminacion directa','Doble eliminacion','Grupos + playoffs') NOT NULL,
status ENUM('draft','active','finished') NOT NULL DEFAULT 'draft',
starts_at DATE,
ends_at DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tournaments_status (status),
INDEX idx_tournaments_category (category)
) ENGINE=InnoDB;
CREATE TABLE teams (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tournament_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(160) NOT NULL,
logo_path VARCHAR(255),
coach_name VARCHAR(160),
delegate_user_id BIGINT UNSIGNED,
registration_token CHAR(32) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_teams_tournament FOREIGN KEY (tournament_id) REFERENCES tournaments(id) ON DELETE CASCADE,
CONSTRAINT fk_teams_delegate FOREIGN KEY (delegate_user_id) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_team_tournament_name (tournament_id, name),
INDEX idx_teams_tournament (tournament_id)
) ENGINE=InnoDB;
CREATE TABLE players (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
team_id BIGINT UNSIGNED NOT NULL,
first_name VARCHAR(120) NOT NULL,
last_name VARCHAR(120) NOT NULL,
document_id VARCHAR(60) NOT NULL,
birth_date DATE,
jersey_number TINYINT UNSIGNED,
position ENUM('Armador','Opuesto','Central','Punta','Libero','Universal'),
photo_path VARCHAR(255),
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_players_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
UNIQUE KEY uq_player_document_team (team_id, document_id),
UNIQUE KEY uq_player_number_team (team_id, jersey_number),
INDEX idx_players_team (team_id)
) ENGINE=InnoDB;
CREATE TABLE courts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
address VARCHAR(220),
active BOOLEAN NOT NULL DEFAULT TRUE
) ENGINE=InnoDB;
CREATE TABLE referees (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(140) NOT NULL,
license VARCHAR(80),
phone VARCHAR(60),
active BOOLEAN NOT NULL DEFAULT TRUE
) ENGINE=InnoDB;
CREATE TABLE matches (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tournament_id BIGINT UNSIGNED NOT NULL,
phase VARCHAR(80) NOT NULL DEFAULT 'regular',
scheduled_at DATETIME,
court_id BIGINT UNSIGNED,
home_team_id BIGINT UNSIGNED NOT NULL,
away_team_id BIGINT UNSIGNED NOT NULL,
status ENUM('scheduled','live','finished','cancelled') NOT NULL DEFAULT 'scheduled',
winner_team_id BIGINT UNSIGNED,
home_sets TINYINT UNSIGNED NOT NULL DEFAULT 0,
away_sets TINYINT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_matches_tournament FOREIGN KEY (tournament_id) REFERENCES tournaments(id) ON DELETE CASCADE,
CONSTRAINT fk_matches_court FOREIGN KEY (court_id) REFERENCES courts(id) ON DELETE SET NULL,
CONSTRAINT fk_matches_home FOREIGN KEY (home_team_id) REFERENCES teams(id),
CONSTRAINT fk_matches_away FOREIGN KEY (away_team_id) REFERENCES teams(id),
CONSTRAINT fk_matches_winner FOREIGN KEY (winner_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT chk_different_teams CHECK (home_team_id <> away_team_id),
INDEX idx_matches_tournament_status (tournament_id, status),
INDEX idx_matches_schedule (scheduled_at)
) ENGINE=InnoDB;
CREATE TABLE match_referees (
match_id BIGINT UNSIGNED NOT NULL,
referee_id BIGINT UNSIGNED NOT NULL,
role VARCHAR(80) NOT NULL DEFAULT 'principal',
PRIMARY KEY (match_id, referee_id),
CONSTRAINT fk_match_referees_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_match_referees_referee FOREIGN KEY (referee_id) REFERENCES referees(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE match_sets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
home_points TINYINT UNSIGNED NOT NULL DEFAULT 0,
away_points TINYINT UNSIGNED NOT NULL DEFAULT 0,
winner_team_id BIGINT UNSIGNED,
side_state ENUM('normal','switched') NOT NULL DEFAULT 'normal',
CONSTRAINT fk_sets_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_sets_winner FOREIGN KEY (winner_team_id) REFERENCES teams(id) ON DELETE SET NULL,
UNIQUE KEY uq_set_match_number (match_id, set_number)
) ENGINE=InnoDB;
CREATE TABLE match_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED,
player_id BIGINT UNSIGNED,
event_type ENUM('serve','point','error','ace','block','attack','rotation','substitution','timeout','libero','yellow_card','red_card','mvp','audit','signature') NOT NULL,
points_home TINYINT UNSIGNED NOT NULL DEFAULT 0,
points_away TINYINT UNSIGNED NOT NULL DEFAULT 0,
notes VARCHAR(255),
created_by BIGINT UNSIGNED,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_events_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_events_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_events_player FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE SET NULL,
CONSTRAINT fk_events_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_events_match_created (match_id, created_at),
INDEX idx_events_player_type (player_id, event_type)
) ENGINE=InnoDB;
CREATE TABLE rotations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
position_number TINYINT UNSIGNED NOT NULL,
player_id BIGINT UNSIGNED NOT NULL,
is_libero BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_rotations_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_rotations_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_rotations_player FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE,
UNIQUE KEY uq_rotation_slot (match_id, team_id, set_number, position_number)
) ENGINE=InnoDB;
CREATE TABLE match_liberos (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED NOT NULL,
player_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
is_starting BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_liberos_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_liberos_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_liberos_player FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE,
UNIQUE KEY uq_match_team_set_libero (match_id, team_id, set_number, player_id)
) ENGINE=InnoDB;
CREATE TABLE substitutions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
player_out_id BIGINT UNSIGNED NOT NULL,
player_in_id BIGINT UNSIGNED NOT NULL,
reason VARCHAR(120),
created_by BIGINT UNSIGNED,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_substitutions_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_substitutions_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_substitutions_out FOREIGN KEY (player_out_id) REFERENCES players(id),
CONSTRAINT fk_substitutions_in FOREIGN KEY (player_in_id) REFERENCES players(id),
CONSTRAINT fk_substitutions_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_substitutions_match_set (match_id, set_number, team_id)
) ENGINE=InnoDB;
CREATE TABLE timeouts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
points_home TINYINT UNSIGNED NOT NULL DEFAULT 0,
points_away TINYINT UNSIGNED NOT NULL DEFAULT 0,
requested_by VARCHAR(120),
created_by BIGINT UNSIGNED,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_timeouts_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_timeouts_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_timeouts_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_timeouts_match_set (match_id, set_number, team_id)
) ENGINE=InnoDB;
CREATE TABLE rally_history (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
set_number TINYINT UNSIGNED NOT NULL,
rally_number INT UNSIGNED NOT NULL,
serving_team_id BIGINT UNSIGNED,
winning_team_id BIGINT UNSIGNED,
result_type ENUM('point','error','ace','block','attack') NOT NULL DEFAULT 'point',
points_home TINYINT UNSIGNED NOT NULL DEFAULT 0,
points_away TINYINT UNSIGNED NOT NULL DEFAULT 0,
notes VARCHAR(255),
created_by BIGINT UNSIGNED,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rally_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_rally_serving_team FOREIGN KEY (serving_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_rally_winning_team FOREIGN KEY (winning_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_rally_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_rally_number (match_id, set_number, rally_number)
) ENGINE=InnoDB;
CREATE TABLE referee_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED,
action VARCHAR(80) NOT NULL,
payload JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_audit_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_audit_match_created (match_id, created_at)
) ENGINE=InnoDB;
CREATE TABLE referee_signatures (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
referee_id BIGINT UNSIGNED,
signer_name VARCHAR(140) NOT NULL,
role VARCHAR(80) NOT NULL DEFAULT 'principal',
signature_hash CHAR(64) NOT NULL,
signed_payload JSON,
signed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_signatures_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_signatures_referee FOREIGN KEY (referee_id) REFERENCES referees(id) ON DELETE SET NULL,
UNIQUE KEY uq_signature_match_role (match_id, role)
) ENGINE=InnoDB;
CREATE TABLE sanctions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
match_id BIGINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED,
player_id BIGINT UNSIGNED,
card_type ENUM('yellow','red') NOT NULL,
reason VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_sanctions_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
CONSTRAINT fk_sanctions_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_sanctions_player FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE team_standings (
tournament_id BIGINT UNSIGNED NOT NULL,
team_id BIGINT UNSIGNED NOT NULL,
played INT UNSIGNED NOT NULL DEFAULT 0,
won INT UNSIGNED NOT NULL DEFAULT 0,
lost INT UNSIGNED NOT NULL DEFAULT 0,
sets_for INT UNSIGNED NOT NULL DEFAULT 0,
sets_against INT UNSIGNED NOT NULL DEFAULT 0,
points INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (tournament_id, team_id),
CONSTRAINT fk_standings_tournament FOREIGN KEY (tournament_id) REFERENCES tournaments(id) ON DELETE CASCADE,
CONSTRAINT fk_standings_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
) ENGINE=InnoDB;

40
database/seeds.sql Normal file
View File

@ -0,0 +1,40 @@
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
INSERT INTO users (name, email, password_hash, role) VALUES
('Admin Volley', 'admin@volley.test', '$2y$10$ECgUnurZ4VOapbKn08r7t.3iYYfu5EjqgZHev.SfcbwzDrvVwiW56', 'admin'),
('Delegado Norte', 'delegado@volley.test', '$2y$10$ECgUnurZ4VOapbKn08r7t.3iYYfu5EjqgZHev.SfcbwzDrvVwiW56', 'delegate');
INSERT INTO tournaments (name, category, age_subcategory, format, status, starts_at)
VALUES ('Liga Apertura Metropolitana', 'Mixto', 'Libre', 'Liga', 'active', CURDATE());
INSERT INTO courts (name, address) VALUES
('Cancha Central', 'Polideportivo Municipal'),
('Cancha Norte', 'Club Barrio Norte');
INSERT INTO referees (name, license, phone) VALUES
('Laura Medina', 'ARB-001', '+54 11 5555-0101'),
('Carlos Rivas', 'ARB-002', '+54 11 5555-0102');
INSERT INTO teams (tournament_id, name, coach_name, delegate_user_id, registration_token) VALUES
(1, 'Condores VC', 'Marta Salas', 2, '11111111111111111111111111111111'),
(1, 'Titanes Sur', 'Julian Paz', NULL, '22222222222222222222222222222222'),
(1, 'Central Volley', 'Noelia Torres', NULL, '33333333333333333333333333333333'),
(1, 'Atletico Red', 'Ramiro Lopez', NULL, '44444444444444444444444444444444');
INSERT INTO players (team_id, first_name, last_name, document_id, birth_date, jersey_number, position) VALUES
(1, 'Sofia', 'Arias', '301', '1998-04-10', 7, 'Punta'),
(1, 'Mateo', 'Benitez', '302', '1996-09-17', 11, 'Armador'),
(2, 'Lucia', 'Campos', '401', '1999-01-20', 4, 'Libero'),
(2, 'Tomas', 'Diaz', '402', '1995-06-02', 9, 'Opuesto'),
(3, 'Paula', 'Funes', '501', '2000-11-01', 6, 'Central'),
(3, 'Bruno', 'Gomez', '502', '1997-03-13', 12, 'Punta'),
(4, 'Camila', 'Herrera', '601', '1998-12-22', 3, 'Armador'),
(4, 'Nicolas', 'Ibarra', '602', '1994-05-09', 14, 'Central');
INSERT INTO matches (tournament_id, phase, scheduled_at, court_id, home_team_id, away_team_id, status)
VALUES
(1, 'regular', DATE_ADD(NOW(), INTERVAL 1 DAY), 1, 1, 2, 'scheduled'),
(1, 'regular', DATE_ADD(NOW(), INTERVAL 2 DAY), 2, 3, 4, 'scheduled');
INSERT INTO team_standings (tournament_id, team_id) VALUES (1,1), (1,2), (1,3), (1,4);

76
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,76 @@
x-app-env: &app-env
APP_ENV: ${APP_ENV:-production}
APP_DEBUG: ${APP_DEBUG:-false}
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: ${DB_DATABASE:-volley_tournaments}
DB_USERNAME: ${DB_USERNAME:-volley}
DB_PASSWORD: ${DB_PASSWORD:-change-volley-password}
JWT_SECRET: ${JWT_SECRET:-change-this-long-random-secret}
CORS_ORIGIN: ${CORS_ORIGIN:-*}
services:
app:
image: ${APP_IMAGE:-volley-manager:latest}
restart: unless-stopped
ports:
- "${APP_PORT:-8080}:80"
environment:
<<: *app-env
volumes:
- uploads_data:/var/www/html/public/uploads
- logs_data:/var/www/html/storage/logs
depends_on:
db:
condition: service_healthy
websocket:
image: ${APP_IMAGE:-volley-manager:latest}
restart: unless-stopped
command: php /var/www/html/bin/websocket-server.php
ports:
- "${WS_PORT:-8081}:8081"
environment:
<<: *app-env
depends_on:
db:
condition: service_healthy
init:
image: ${APP_IMAGE:-volley-manager:latest}
profiles:
- init
command: php /var/www/html/scripts/seed_ltv26_template.php
environment:
<<: *app-env
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.4
restart: unless-stopped
ports:
- "${MYSQL_PORT:-3307}:3306"
environment:
MYSQL_DATABASE: ${DB_DATABASE:-volley_tournaments}
MYSQL_USER: ${DB_USERNAME:-volley}
MYSQL_PASSWORD: ${DB_PASSWORD:-change-volley-password}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-change-root-password}
volumes:
- db_data:/var/lib/mysql
- ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./database/seeds.sql:/docker-entrypoint-initdb.d/02-seeds.sql:ro
- ./database/migrations/20260519_scoresheet_advanced.sql:/docker-entrypoint-initdb.d/03-scoresheet-advanced.sql:ro
- ./database/migrations/20260519_ltv26_template_fields.sql:/docker-entrypoint-initdb.d/04-ltv26-template-fields.sql:ro
- ./database/migrations/20260519_sheet_templates.sql:/docker-entrypoint-initdb.d/05-sheet-templates.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${DB_ROOT_PASSWORD:-change-root-password}"]
interval: 5s
timeout: 5s
retries: 20
volumes:
db_data:
uploads_data:
logs_data:

61
docker-compose.yml Normal file
View File

@ -0,0 +1,61 @@
services:
app:
build: .
ports:
- "8080:80"
environment:
APP_ENV: local
APP_DEBUG: "true"
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: volley_tournaments
DB_USERNAME: volley
DB_PASSWORD: volley
JWT_SECRET: "dev-secret-change-me"
volumes:
- .:/var/www/html
depends_on:
db:
condition: service_healthy
websocket:
build: .
command: php /var/www/html/bin/websocket-server.php
ports:
- "8081:8081"
environment:
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: volley_tournaments
DB_USERNAME: volley
DB_PASSWORD: volley
volumes:
- .:/var/www/html
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.4
ports:
- "3307:3306"
environment:
MYSQL_DATABASE: volley_tournaments
MYSQL_USER: volley
MYSQL_PASSWORD: volley
MYSQL_ROOT_PASSWORD: root
volumes:
- db_data:/var/lib/mysql
- ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./database/seeds.sql:/docker-entrypoint-initdb.d/02-seeds.sql:ro
- ./database/migrations/20260519_scoresheet_advanced.sql:/docker-entrypoint-initdb.d/03-scoresheet-advanced.sql:ro
- ./database/migrations/20260519_ltv26_template_fields.sql:/docker-entrypoint-initdb.d/04-ltv26-template-fields.sql:ro
- ./database/migrations/20260519_sheet_templates.sql:/docker-entrypoint-initdb.d/05-sheet-templates.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
interval: 5s
timeout: 5s
retries: 20
volumes:
db_data:

9
docker/apache.conf Normal file
View File

@ -0,0 +1,9 @@
<VirtualHost *:80>
DocumentRoot /var/www/html/public
<Directory /var/www/html/public>
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

1025
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

9
package.json Normal file
View File

@ -0,0 +1,9 @@
{
"scripts": {
"build:css": "tailwindcss -i ./resources/css/tailwind.css -o ./public/assets/tailwind.css --minify",
"watch:css": "tailwindcss -i ./resources/css/tailwind.css -o ./public/assets/tailwind.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.17"
}
}

5
public/.htaccess Normal file
View File

@ -0,0 +1,5 @@
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

117
public/app.html Normal file
View File

@ -0,0 +1,117 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Volley Manager</title>
<link rel="stylesheet" href="/assets/tailwind.css?v=<?= filemtime(__DIR__ . '/assets/tailwind.css') ?>">
<link rel="stylesheet" href="/assets/styles.css?v=<?= filemtime(__DIR__ . '/assets/styles.css') ?>">
</head>
<body class="bg-slate-100 text-slate-900 dark:bg-zinc-950 dark:text-zinc-100">
<div class="site-bg" aria-hidden="true"></div>
<div id="toast" class="fixed right-4 top-4 z-50 space-y-2"></div>
<div class="app-shell">
<header class="mobile-topbar">
<button id="mobileMenuBtn" class="mobile-menu-btn" type="button" title="Abrir menu">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16"></path><path d="M4 12h16"></path><path d="M4 18h16"></path></svg>
</button>
<button class="brand-mark" data-route="public" title="Volley Manager">VM</button>
<div class="min-w-0">
<p class="truncate text-sm font-black">Volley Manager</p>
<p class="truncate text-xs text-slate-500 dark:text-zinc-400">Torneos y planilla</p>
</div>
</header>
<div id="mobileMenuBackdrop" class="mobile-menu-backdrop"></div>
<aside id="sidebar" class="sidebar">
<div class="sidebar-brand">
<button class="brand-mark" data-route="public" title="Volley Manager">VM</button>
<div class="sidebar-label min-w-0">
<p class="truncate text-sm font-black">Volley Manager</p>
<p class="truncate text-xs text-slate-500 dark:text-zinc-400">Torneos y planilla</p>
</div>
<button id="sidebarToggle" class="sidebar-toggle" title="Minimizar menu" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"></path></svg>
</button>
</div>
<nav class="sidebar-nav" aria-label="Menu principal">
<button class="nav-btn sidebar-nav-item" data-route="public" title="Publico">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 10l8-6 8 6v10H4z"></path><path d="M9 20v-6h6v6"></path></svg></span>
<span class="sidebar-label">Publico</span>
</button>
<button class="nav-btn sidebar-nav-item" data-route="admin" title="Admin">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M12 3l8 4v6c0 5-3.5 7.5-8 8-4.5-.5-8-3-8-8V7z"></path><path d="M9 12l2 2 4-4"></path></svg></span>
<span class="sidebar-label">Admin</span>
</button>
<button class="nav-btn sidebar-nav-item" data-route="score" title="Planilla">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M6 3h12v18H6z"></path><path d="M9 7h6"></path><path d="M9 11h6"></path><path d="M9 15h3"></path></svg></span>
<span class="sidebar-label">Planilla</span>
</button>
<button class="nav-btn sidebar-nav-item" data-route="team-link" title="Ficha online">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M8 7a4 4 0 1 0 8 0 4 4 0 0 0-8 0z"></path><path d="M4 21a8 8 0 0 1 16 0"></path></svg></span>
<span class="sidebar-label">Ficha online</span>
</button>
</nav>
<div class="sidebar-panels">
<section class="sidebar-panel">
<h2 class="section-title sidebar-label">Sesion</h2>
<div class="sidebar-panel-content">
<div class="min-w-0">
<p id="sessionUser" class="truncate text-sm font-black">Invitado</p>
<p id="sessionRole" class="text-xs text-slate-500">Modo publico</p>
</div>
<button id="sideLoginBtn" class="btn-primary w-full whitespace-nowrap">Ingresar</button>
</div>
<button id="loginBtn" class="sidebar-mini-action" title="Ingresar" type="button">
<svg viewBox="0 0 24 24"><path d="M10 17l5-5-5-5"></path><path d="M15 12H3"></path><path d="M15 4h4v16h-4"></path></svg>
</button>
</section>
<section class="sidebar-panel">
<h2 class="section-title sidebar-label">Torneo activo</h2>
<div class="sidebar-panel-content">
<select id="tournamentSelect" class="input"></select>
<p id="sessionInfo" class="mt-3 text-xs text-slate-500">Modo publico</p>
<div class="mt-3 grid grid-cols-2 gap-2 text-sm">
<button class="btn-muted" id="refreshBtn">Actualizar</button>
<button class="btn-muted" id="fixtureBtn">Fixture auto</button>
</div>
</div>
</section>
</div>
<div class="sidebar-footer">
<button id="themeBtn" class="sidebar-nav-item theme-item" title="Cambiar tema" type="button">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9z"></path></svg></span>
<span class="sidebar-label">Tema</span>
</button>
</div>
</aside>
<main class="app-main">
<section id="view" class="min-h-[70vh]"></section>
</main>
</div>
<div id="modalRoot" class="modal-root hidden" aria-hidden="true">
<div class="modal-backdrop" data-modal-close></div>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-header">
<div>
<p id="modalEyebrow" class="modal-eyebrow"></p>
<h2 id="modalTitle" class="modal-title"></h2>
<p id="modalSubtitle" class="modal-subtitle"></p>
</div>
<button class="modal-close" type="button" data-modal-close title="Cerrar">x</button>
</div>
<div id="modalBody" class="modal-body"></div>
<div id="modalFooter" class="modal-footer hidden"></div>
</section>
</div>
<script src="/assets/app.js?v=<?= filemtime(__DIR__ . '/assets/app.js') ?>"></script>
</body>
</html>

1334
public/assets/app.js Normal file

File diff suppressed because it is too large Load Diff

155
public/assets/styles.css Normal file
View File

@ -0,0 +1,155 @@
.site-bg {
position: fixed;
inset: 0;
z-index: -2;
background-image: url("https://images.pexels.com/photos/30307748/pexels-photo-30307748.jpeg?auto=compress&cs=tinysrgb&w=1920");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.site-bg::after {
content: "";
position: absolute;
inset: 0;
background: rgba(226, 232, 240, .50);
}
.dark .site-bg::after {
background: rgba(9, 9, 11, .80);
}
.mobile-topbar { display: none; }
.mobile-menu-backdrop { display: none; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 292px minmax(0, 1fr); transition: grid-template-columns .2s ease; }
body.sidebar-collapsed .app-shell { grid-template-columns: 76px minmax(0, 1fr); }
.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; gap: 14px; border-right: 1px solid rgb(226 232 240); background: rgba(255,255,255,.94); padding: 14px; backdrop-filter: blur(12px); overflow: hidden; }
.dark .sidebar { border-color: rgb(39 39 42); background: rgba(24,24,27,.94); }
.app-main { min-width: 0; padding: 18px; }
.sidebar-brand { display: flex; align-items: center; gap: 10px; min-height: 44px; }
.brand-mark { display: grid; place-items: center; width: 42px; height: 42px; flex: 0 0 42px; border-radius: 8px; background: #16a34a; color: white; font-size: 13px; font-weight: 950; }
.sidebar-toggle { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 34px; margin-left: auto; border-radius: 8px; border: 1px solid rgb(203 213 225); background: white; color: rgb(71 85 105); }
.dark .sidebar-toggle { border-color: rgb(63 63 70); background: rgb(39 39 42); color: white; }
.sidebar-toggle svg, .nav-icon svg, .sidebar-mini-action svg, .mobile-menu-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
body.sidebar-collapsed .sidebar-toggle svg { transform: rotate(180deg); }
.sidebar-nav { display: grid; gap: 8px; }
.sidebar-nav-item { width: 100%; display: flex; align-items: center; gap: 10px; border-radius: 8px; border: 1px solid transparent; background: transparent; padding: 10px; color: rgb(51 65 85); font-size: 14px; font-weight: 850; text-align: left; transition: background .15s ease, color .15s ease, border-color .15s ease; }
.sidebar-nav-item:hover { border-color: rgb(187 247 208); background: rgb(240 253 244); color: #166534; }
.dark .sidebar-nav-item { color: rgb(212 212 216); }
.dark .sidebar-nav-item:hover { border-color: rgba(34,197,94,.34); background: rgba(22,163,74,.14); color: #86efac; }
.sidebar-nav-item.active { border-color: #16a34a; background: #16a34a; color: white; box-shadow: 0 10px 24px rgba(22,163,74,.24); }
.nav-icon { display: grid; place-items: center; width: 22px; height: 22px; flex: 0 0 22px; }
.sidebar-panels { display: grid; gap: 12px; min-width: 0; }
.sidebar-panel { position: relative; border: 1px solid rgb(226 232 240); background: rgba(248,250,252,.8); border-radius: 8px; padding: 14px; min-width: 0; }
.dark .sidebar-panel { border-color: rgb(39 39 42); background: rgba(39,39,42,.72); }
.sidebar-panel-content { display: grid; gap: 10px; }
.sidebar-mini-action { display: none; width: 44px; height: 44px; place-items: center; border-radius: 8px; border: 1px solid rgb(203 213 225); background: white; color: #16a34a; }
.dark .sidebar-mini-action { border-color: rgb(63 63 70); background: rgb(39 39 42); }
.sidebar-footer { margin-top: auto; }
body.sidebar-collapsed .sidebar { align-items: center; padding-left: 12px; padding-right: 12px; }
body.sidebar-collapsed .sidebar-label, body.sidebar-collapsed .sidebar-panel-content { display: none; }
body.sidebar-collapsed .sidebar-brand { justify-content: center; }
body.sidebar-collapsed .sidebar-toggle { margin-left: 0; }
body.sidebar-collapsed .sidebar-nav-item { justify-content: center; padding: 11px; }
body.sidebar-collapsed .sidebar-panel { padding: 8px; border-color: transparent; background: transparent; }
body.sidebar-collapsed .sidebar-mini-action { display: grid; }
body.sidebar-collapsed .theme-item { width: 46px; }
.panel { border: 1px solid rgb(226 232 240); background: rgba(255,255,255,.92); border-radius: 8px; padding: 16px; }
.dark .panel { border-color: rgb(39 39 42); background: rgba(24,24,27,.92); }
.section-title { font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; color: rgb(71 85 105); margin-bottom: 10px; }
.dark .section-title { color: rgb(161 161 170); }
.input { width: 100%; border: 1px solid rgb(203 213 225); background: white; border-radius: 6px; padding: 10px 11px; font-size: 14px; outline: none; }
.dark .input { border-color: rgb(63 63 70); background: rgb(39 39 42); color: white; }
.btn-primary { border-radius: 6px; background: #16a34a; color: white; padding: 10px 12px; font-weight: 800; }
.btn-muted, .icon-btn { border-radius: 6px; border: 1px solid rgb(203 213 225); padding: 9px 11px; font-weight: 700; background: white; }
.dark .btn-muted, .dark .icon-btn { border-color: rgb(63 63 70); background: rgb(39 39 42); color: white; }
.action-tile { min-height: 108px; border-radius: 8px; border: 1px solid rgb(203 213 225); background: white; padding: 18px; text-align: left; transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; }
.action-tile:hover { transform: translateY(-1px); border-color: #16a34a; box-shadow: 0 12px 30px rgba(15,23,42,.08); }
.dark .action-tile { border-color: rgb(63 63 70); background: rgb(24 24 27); }
.dashboard-actions { display: grid; grid-template-columns: repeat(6, minmax(140px, 1fr)); gap: 12px; }
.dashboard-panels { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.action-icon { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 8px; background: #dcfce7; color: #15803d; font-weight: 950; }
.dark .action-icon { background: rgba(22,163,74,.18); color: #4ade80; }
.segmented { display: inline-grid; grid-auto-flow: column; gap: 4px; border: 1px solid rgb(203 213 225); background: rgb(248 250 252); border-radius: 8px; padding: 4px; }
.dark .segmented { border-color: rgb(63 63 70); background: rgb(39 39 42); }
.segmented button { border-radius: 6px; padding: 8px 12px; font-size: 13px; font-weight: 900; color: rgb(71 85 105); }
.dark .segmented button { color: rgb(212 212 216); }
.segmented button.active { background: #16a34a; color: white; }
.stepper { display: grid; gap: 10px; }
.step-row { display: flex; gap: 10px; align-items: flex-start; padding: 12px; border: 1px solid rgb(226 232 240); border-radius: 8px; background: rgba(255,255,255,.72); }
.dark .step-row { border-color: rgb(39 39 42); background: rgba(39,39,42,.72); }
.step-number { display: grid; place-items: center; width: 30px; height: 30px; flex: 0 0 30px; border-radius: 999px; background: #16a34a; color: white; font-weight: 950; }
.step-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.card-choice { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.card-choice input { position: absolute; opacity: 0; pointer-events: none; }
.card-choice label { display: flex; align-items: center; gap: 10px; border: 1px solid rgb(203 213 225); border-radius: 8px; padding: 12px; font-weight: 900; cursor: pointer; background: white; }
.dark .card-choice label { border-color: rgb(63 63 70); background: rgb(39 39 42); }
.card-choice input:checked + label { border-color: #16a34a; box-shadow: 0 0 0 2px rgba(22,163,74,.18); }
.card-swatch { width: 24px; height: 34px; border-radius: 3px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.18); }
.card-swatch.yellow { background: #facc15; }
.card-swatch.red { background: #dc2626; }
.template-editor { display: grid; gap: 14px; grid-template-columns: minmax(0, 1fr) 300px; align-items: start; }
.template-canvas-wrap { overflow: auto; border: 1px solid rgb(203 213 225); border-radius: 8px; background: rgba(15,23,42,.08); padding: 10px; }
.dark .template-canvas-wrap { border-color: rgb(63 63 70); background: rgba(0,0,0,.24); }
.template-canvas { position: relative; width: 100%; max-width: 1100px; margin: 0 auto; background: white; box-shadow: 0 16px 40px rgba(15,23,42,.18); }
.template-canvas:focus { outline: 2px solid rgba(22,163,74,.5); outline-offset: 4px; }
.template-canvas img { display: block; width: 100%; height: auto; user-select: none; pointer-events: none; }
.template-field { position: absolute; min-width: 44px; min-height: 20px; border: 2px solid #16a34a; border-radius: 4px; background: rgba(220,252,231,.78); color: #052e16; font-size: 12px; font-weight: 900; cursor: move; overflow: hidden; display: flex; align-items: center; padding: 2px 5px; }
.template-field.active { border-color: #f97316; background: rgba(255,237,213,.86); }
.template-side { display: grid; gap: 12px; }
.template-field-list { display: grid; gap: 6px; max-height: 220px; overflow: auto; }
.template-field-list button { text-align: left; }
@media (max-width: 900px) {
.template-editor { grid-template-columns: 1fr; }
.dashboard-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-panels { grid-template-columns: 1fr; }
}
.modal-root { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 18px; }
.modal-root.hidden { display: none; }
.modal-backdrop { position: absolute; inset: 0; background: rgba(2,6,23,.68); backdrop-filter: blur(10px); }
.modal-card { position: relative; width: min(100%, 480px); max-height: calc(100vh - 36px); overflow: hidden; border: 1px solid rgba(255,255,255,.8); border-radius: 8px; background: white; box-shadow: 0 32px 90px rgba(2,6,23,.38); }
.modal-card::before { content: ""; display: block; height: 7px; background: linear-gradient(90deg, #16a34a, #0f172a 48%, #f97316); }
.dark .modal-card { border-color: rgb(63 63 70); background: rgb(24 24 27); box-shadow: 0 32px 90px rgba(0,0,0,.62); }
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 24px 24px 16px; background: linear-gradient(180deg, rgb(248 250 252), white); border-bottom: 1px solid rgb(226 232 240); }
.dark .modal-header { border-color: rgb(39 39 42); background: linear-gradient(180deg, rgb(39 39 42), rgb(24 24 27)); }
.modal-eyebrow { min-height: 16px; font-size: 11px; font-weight: 950; letter-spacing: .12em; text-transform: uppercase; color: #16a34a; }
.modal-title { margin-top: 4px; font-size: 28px; line-height: 1.08; font-weight: 950; color: rgb(15 23 42); }
.dark .modal-title { color: white; }
.modal-subtitle { margin-top: 8px; max-width: 34rem; font-size: 14px; line-height: 1.45; color: rgb(100 116 139); }
.dark .modal-subtitle { color: rgb(161 161 170); }
.modal-close { display: grid; place-items: center; width: 36px; height: 36px; flex: 0 0 36px; border-radius: 8px; border: 1px solid rgb(203 213 225); background: white; font-size: 18px; font-weight: 900; color: rgb(71 85 105); box-shadow: 0 4px 14px rgba(15,23,42,.08); }
.modal-close:hover { border-color: #16a34a; color: #15803d; }
.dark .modal-close { border-color: rgb(63 63 70); background: rgb(39 39 42); color: white; }
.modal-body { padding: 22px 24px 24px; overflow: auto; max-height: calc(100vh - 190px); }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid rgb(226 232 240); }
.dark .modal-footer { border-color: rgb(39 39 42); }
.field { display: grid; gap: 7px; }
.field-label { font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: rgb(71 85 105); }
.dark .field-label { color: rgb(161 161 170); }
.login-panel { border: 1px solid rgb(226 232 240); border-radius: 8px; padding: 14px; background: rgb(248 250 252); }
.dark .login-panel { border-color: rgb(39 39 42); background: rgb(39 39 42); }
.login-badge { display: inline-flex; align-items: center; gap: 8px; border-radius: 999px; background: #dcfce7; color: #166534; padding: 6px 10px; font-size: 12px; font-weight: 900; }
.dark .login-badge { background: rgba(22,163,74,.18); color: #86efac; }
.score-btn { border-radius: 8px; padding: 18px 12px; font-size: 20px; font-weight: 900; color: white; min-height: 76px; }
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th { text-align: left; font-size: 12px; text-transform: uppercase; color: rgb(100 116 139); border-bottom: 1px solid rgb(226 232 240); padding: 10px 8px; }
.table td { border-bottom: 1px solid rgb(226 232 240); padding: 10px 8px; }
.dark .table th, .dark .table td { border-color: rgb(39 39 42); }
@media (max-width: 640px) {
.app-shell { display: block; min-height: 100vh; padding-top: 68px; }
.mobile-topbar { position: fixed; top: 0; left: 0; right: 0; z-index: 45; display: flex; align-items: center; gap: 10px; min-height: 68px; padding: 10px 14px; border-bottom: 1px solid rgb(226 232 240); background: rgba(255,255,255,.94); backdrop-filter: blur(12px); }
.dark .mobile-topbar { border-color: rgb(39 39 42); background: rgba(24,24,27,.94); }
.mobile-menu-btn { display: grid; place-items: center; width: 42px; height: 42px; flex: 0 0 42px; border-radius: 8px; border: 1px solid rgb(203 213 225); background: white; color: rgb(51 65 85); }
.dark .mobile-menu-btn { border-color: rgb(63 63 70); background: rgb(39 39 42); color: white; }
.sidebar { position: fixed; z-index: 55; top: 0; left: 0; width: min(86vw, 292px); height: 100vh; align-items: stretch; padding: 14px; transform: translateX(-105%); transition: transform .2s ease; box-shadow: 22px 0 60px rgba(15,23,42,.22); }
body.mobile-menu-open .sidebar { transform: translateX(0); }
.mobile-menu-backdrop { position: fixed; inset: 0; z-index: 50; background: rgba(15,23,42,.52); backdrop-filter: blur(4px); }
body.mobile-menu-open .mobile-menu-backdrop { display: block; }
.sidebar-label, .sidebar-panel-content { display: block; }
.sidebar-brand { justify-content: flex-start; }
.sidebar-toggle { display: none; }
.sidebar-nav-item { justify-content: flex-start; padding: 10px; }
.sidebar-panel { padding: 14px; border-color: rgb(226 232 240); background: rgba(248,250,252,.88); }
.dark .sidebar-panel { border-color: rgb(39 39 42); background: rgba(39,39,42,.82); }
.sidebar-mini-action { display: none; }
.app-main { padding: 12px; }
.table { font-size: 12px; }
.table th, .table td { padding: 8px 5px; }
}

File diff suppressed because one or more lines are too long

98
public/index.php Normal file
View File

@ -0,0 +1,98 @@
<?php
use App\Controllers\AuthController;
use App\Controllers\CatalogController;
use App\Controllers\ExportController;
use App\Controllers\MatchController;
use App\Controllers\MatchSheetController;
use App\Controllers\PlayerController;
use App\Controllers\PublicController;
use App\Controllers\ScoresheetExportController;
use App\Controllers\SheetTemplateController;
use App\Controllers\TeamController;
use App\Controllers\TournamentController;
use App\Controllers\UploadController;
use App\Controllers\UserController;
use App\Core\Log;
use App\Core\Response;
use App\Core\Router;
spl_autoload_register(function (string $class): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$path = __DIR__ . '/../app/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($path)) {
require $path;
}
});
$config = require __DIR__ . '/../config/app.php';
header('Access-Control-Allow-Origin: ' . $config['cors_origin']);
header('Access-Control-Allow-Headers: Authorization, Content-Type');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit;
}
if (!str_starts_with(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/', '/api')) {
require __DIR__ . '/app.html';
exit;
}
$router = new Router();
$router->add('POST', '/api/auth/login', [AuthController::class, 'login']);
$router->add('GET', '/api/auth/me', [AuthController::class, 'me']);
$router->add('GET', '/api/users', [UserController::class, 'index']);
$router->add('POST', '/api/users', [UserController::class, 'store']);
$router->add('PUT', '/api/users/{id}', [UserController::class, 'update']);
$router->add('GET', '/api/tournaments', [TournamentController::class, 'index']);
$router->add('POST', '/api/tournaments', [TournamentController::class, 'store']);
$router->add('POST', '/api/tournaments/{id}/fixture', [MatchController::class, 'generateFixture']);
$router->add('POST', '/api/tournaments/{id}/demo-scoresheet-data', [TournamentController::class, 'demoScoresheet']);
$router->add('GET', '/api/tournaments/{id}/standings', [PublicController::class, 'standings']);
$router->add('GET', '/api/tournaments/{id}/stats', [PublicController::class, 'stats']);
$router->add('GET', '/api/tournaments/{id}/export/csv', [ExportController::class, 'standingsCsv']);
$router->add('GET', '/api/tournaments/{id}/export/pdf', [ExportController::class, 'standingsPdf']);
$router->add('POST', '/api/tournaments/{id}/sheet-template', [SheetTemplateController::class, 'assignTournament']);
$router->add('GET', '/api/sheet-templates', [SheetTemplateController::class, 'index']);
$router->add('POST', '/api/sheet-templates', [SheetTemplateController::class, 'store']);
$router->add('GET', '/api/sheet-templates/{id}', [SheetTemplateController::class, 'show']);
$router->add('GET', '/api/sheet-templates/{id}/effective', [SheetTemplateController::class, 'effective']);
$router->add('PUT', '/api/sheet-templates/{id}', [SheetTemplateController::class, 'update']);
$router->add('GET', '/api/teams', [TeamController::class, 'index']);
$router->add('POST', '/api/teams', [TeamController::class, 'store']);
$router->add('GET', '/api/players', [PlayerController::class, 'index']);
$router->add('POST', '/api/players', [PlayerController::class, 'store']);
$router->add('POST', '/api/team-links/{token}/players', [PlayerController::class, 'registerByLink']);
$router->add('GET', '/api/matches', [MatchController::class, 'index']);
$router->add('POST', '/api/matches', [MatchController::class, 'store']);
$router->add('GET', '/api/matches/{id}/score', [MatchController::class, 'scoreState']);
$router->add('GET', '/api/matches/{id}/scoresheet/ltv26', [ScoresheetExportController::class, 'ltv26']);
$router->add('POST', '/api/matches/{id}/sheet-template', [SheetTemplateController::class, 'overrideMatch']);
$router->add('POST', '/api/matches/{id}/events', [MatchController::class, 'scoreEvent']);
$router->add('GET', '/api/matches/{id}/advanced-score', [MatchController::class, 'advancedState']);
$router->add('GET', '/api/matches/{id}/sheet-details', [MatchSheetController::class, 'index']);
$router->add('POST', '/api/matches/{id}/sheet-details', [MatchSheetController::class, 'save']);
$router->add('POST', '/api/matches/{id}/rotations', [MatchController::class, 'rotation']);
$router->add('POST', '/api/matches/{id}/liberos', [MatchController::class, 'libero']);
$router->add('POST', '/api/matches/{id}/substitutions', [MatchController::class, 'substitution']);
$router->add('POST', '/api/matches/{id}/timeouts', [MatchController::class, 'timeout']);
$router->add('POST', '/api/matches/{id}/rallies', [MatchController::class, 'rally']);
$router->add('POST', '/api/matches/{id}/advanced-sanctions', [MatchController::class, 'advancedSanction']);
$router->add('POST', '/api/matches/{id}/signatures', [MatchController::class, 'signature']);
$router->add('POST', '/api/matches/{id}/sanctions', [CatalogController::class, 'sanction']);
$router->add('GET', '/api/courts', [CatalogController::class, 'courts']);
$router->add('GET', '/api/referees', [CatalogController::class, 'referees']);
$router->add('POST', '/api/uploads/images', [UploadController::class, 'image']);
try {
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
} catch (Throwable $e) {
Log::error($e->getMessage(), ['trace' => $config['debug'] ? $e->getTraceAsString() : null]);
Response::error($config['debug'] ? $e->getMessage() : 'Error interno', 500);
}

View File

@ -0,0 +1,15 @@
# Plantillas de planilla
Para la planilla LTV 26, exportar el PDF-imagen a PNG y guardarlo como:
```text
public/templates/planilla-ltv-26.png
```
El exportador usa ese PNG como fondo y dibuja encima los datos con las coordenadas de:
```text
config/templates/ltv26_scoresheet.php
```
Si los textos no caen exactamente en las celdas, ajustar los valores `x`, `y`, `w` y `size` en ese archivo.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

11
scripts/install.ps1 Normal file
View File

@ -0,0 +1,11 @@
param(
[string]$MysqlHost = "127.0.0.1",
[string]$MysqlUser = "volley",
[string]$MysqlPassword = "volley",
[string]$Database = "volley_tournaments"
)
Write-Host "Importando schema y seeds en $Database..."
mysql -h $MysqlHost -u $MysqlUser -p$MysqlPassword $Database < database/schema.sql
mysql -h $MysqlHost -u $MysqlUser -p$MysqlPassword $Database < database/seeds.sql
Write-Host "Listo. Usuario: admin@volley.test / password"

11
scripts/install.sh Normal file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env sh
set -eu
MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}"
MYSQL_USER="${MYSQL_USER:-volley}"
MYSQL_PASSWORD="${MYSQL_PASSWORD:-volley}"
MYSQL_DATABASE="${MYSQL_DATABASE:-volley_tournaments}"
mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" "-p$MYSQL_PASSWORD" "$MYSQL_DATABASE" < database/schema.sql
mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" "-p$MYSQL_PASSWORD" "$MYSQL_DATABASE" < database/seeds.sql
echo "Listo. Usuario: admin@volley.test / password"

View File

@ -0,0 +1,39 @@
<?php
require __DIR__ . '/../app/Core/Database.php';
use App\Core\Database;
$config = require __DIR__ . '/../config/templates/ltv26_scoresheet.php';
$image = $config['image'];
$width = $config['width'];
$height = $config['height'];
unset($config['image'], $config['width'], $config['height']);
$db = Database::connection();
$stmt = $db->prepare(
'INSERT INTO sheet_templates (code, name, image_path, page_width, page_height, config_json, active)
VALUES ("ltv26", "Planilla LTV 26", :image_path, :page_width, :page_height, :config_json, 1)
ON DUPLICATE KEY UPDATE image_path = VALUES(image_path), page_width = VALUES(page_width), page_height = VALUES(page_height), config_json = VALUES(config_json), active = 1'
);
$stmt->execute([
'image_path' => $image,
'page_width' => $width,
'page_height' => $height,
'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$templateId = (int) $db->lastInsertId();
if ($templateId === 0) {
$templateId = (int) $db->query('SELECT id FROM sheet_templates WHERE code = "ltv26"')->fetch()['id'];
}
$tournaments = $db->query('SELECT id FROM tournaments')->fetchAll();
foreach ($tournaments as $tournament) {
$db->prepare(
'INSERT IGNORE INTO tournament_sheet_templates (tournament_id, sheet_template_id, is_default)
VALUES (:tournament_id, :sheet_template_id, 1)'
)->execute(['tournament_id' => $tournament['id'], 'sheet_template_id' => $templateId]);
}
echo "Plantilla LTV26 registrada con ID $templateId\n";

15
tailwind.config.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
darkMode: 'class',
content: [
'./public/app.html',
'./public/assets/app.js'
],
theme: {
extend: {
colors: {
court: '#16a34a',
ink: '#111827'
}
}
}
};

13
tests/JwtTest.php Normal file
View File

@ -0,0 +1,13 @@
<?php
require __DIR__ . '/../app/Core/Jwt.php';
use App\Core\Jwt;
$token = Jwt::encode(['sub' => 1, 'role' => 'admin']);
$payload = Jwt::decode($token);
assert($payload['sub'] === 1);
assert($payload['role'] === 'admin');
echo "JwtTest OK\n";

15
tests/ScoreRulesTest.php Normal file
View File

@ -0,0 +1,15 @@
<?php
function setIsFinished(int $home, int $away, int $setNumber): bool
{
$target = $setNumber === 5 ? 15 : 25;
return max($home, $away) >= $target && abs($home - $away) >= 2;
}
assert(setIsFinished(25, 23, 1) === true);
assert(setIsFinished(25, 24, 1) === false);
assert(setIsFinished(16, 14, 5) === true);
assert(setIsFinished(15, 14, 5) === false);
assert(setIsFinished(30, 28, 2) === true);
echo "ScoreRulesTest OK\n";

8
torneos.code-workspace Normal file
View File

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}