Primera version parea produccion
This commit is contained in:
parent
833b4e198b
commit
6f9ccd5fc6
|
|
@ -0,0 +1,11 @@
|
|||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
docker-compose.override.yml
|
||||
node_modules
|
||||
vendor
|
||||
storage/logs/*
|
||||
public/uploads/*
|
||||
torneos.code-workspace
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
storage/logs/*
|
||||
public/uploads/*
|
||||
|
|
@ -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
|
||||
|
|
@ -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'],
|
||||
]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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']));
|
||||
}
|
||||
}
|
||||
|
|
@ -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="missing">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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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, '-_', '+/')) ?: '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 : [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -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],
|
||||
],
|
||||
];
|
||||
|
|
@ -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 ('DÃ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');
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./public/app.html',
|
||||
'./public/assets/app.js'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
court: '#16a34a',
|
||||
ink: '#111827'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
Loading…
Reference in New Issue