diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a28e0b4
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+.git
+.gitignore
+.env
+.env.*
+*.log
+docker-compose.override.yml
+node_modules
+vendor
+storage/logs/*
+public/uploads/*
+torneos.code-workspace
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b757bdd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+.env
+.env.*
+*.log
+storage/logs/*
+public/uploads/*
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..cb21fb9
--- /dev/null
+++ b/Dockerfile
@@ -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
diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php
new file mode 100644
index 0000000..ecf1589
--- /dev/null
+++ b/app/Controllers/AuthController.php
@@ -0,0 +1,44 @@
+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'],
+ ]]);
+ }
+}
diff --git a/app/Controllers/CatalogController.php b/app/Controllers/CatalogController.php
new file mode 100644
index 0000000..cec789c
--- /dev/null
+++ b/app/Controllers/CatalogController.php
@@ -0,0 +1,39 @@
+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);
+ }
+}
diff --git a/app/Controllers/ExportController.php b/app/Controllers/ExportController.php
new file mode 100644
index 0000000..76a5590
--- /dev/null
+++ b/app/Controllers/ExportController.php
@@ -0,0 +1,33 @@
+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 '
Tabla de posiciones';
+ echo 'Tabla de posiciones
Usar imprimir / guardar como PDF desde el navegador.
| Equipo | PJ | G | P | SF | SC | Pts |
';
+ foreach ($rows as $row) {
+ echo sprintf('| %s | %s | %s | %s | %s | %s | %s |
',
+ htmlspecialchars($row['name']), $row['played'], $row['won'], $row['lost'], $row['sets_for'], $row['sets_against'], $row['points']);
+ }
+ echo '
';
+ }
+}
diff --git a/app/Controllers/MatchController.php b/app/Controllers/MatchController.php
new file mode 100644
index 0000000..1eb6423
--- /dev/null
+++ b/app/Controllers/MatchController.php
@@ -0,0 +1,103 @@
+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);
+ }
+ }
+}
diff --git a/app/Controllers/MatchSheetController.php b/app/Controllers/MatchSheetController.php
new file mode 100644
index 0000000..7d5a938
--- /dev/null
+++ b/app/Controllers/MatchSheetController.php
@@ -0,0 +1,53 @@
+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();
+ }
+}
diff --git a/app/Controllers/PlayerController.php b/app/Controllers/PlayerController.php
new file mode 100644
index 0000000..aed9333
--- /dev/null
+++ b/app/Controllers/PlayerController.php
@@ -0,0 +1,46 @@
+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);
+ }
+}
diff --git a/app/Controllers/PublicController.php b/app/Controllers/PublicController.php
new file mode 100644
index 0000000..be705ad
--- /dev/null
+++ b/app/Controllers/PublicController.php
@@ -0,0 +1,19 @@
+standings((int) $params['id']));
+ }
+
+ public function stats(array $params): void
+ {
+ Response::json((new ScoreSheetService())->stats((int) $params['id']));
+ }
+}
diff --git a/app/Controllers/ScoresheetExportController.php b/app/Controllers/ScoresheetExportController.php
new file mode 100644
index 0000000..f86003a
--- /dev/null
+++ b/app/Controllers/ScoresheetExportController.php
@@ -0,0 +1,159 @@
+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 'Planilla LTV 26'
+ . ''
+ . ''
+ . '
'
+ . implode('', $items)
+ . '';
+ }
+
+ 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 '' . htmlspecialchars($value) . '';
+ }
+
+ 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();
+ }
+}
diff --git a/app/Controllers/SheetTemplateController.php b/app/Controllers/SheetTemplateController.php
new file mode 100644
index 0000000..7f8ca34
--- /dev/null
+++ b/app/Controllers/SheetTemplateController.php
@@ -0,0 +1,71 @@
+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]);
+ }
+}
diff --git a/app/Controllers/TeamController.php b/app/Controllers/TeamController.php
new file mode 100644
index 0000000..f7e1012
--- /dev/null
+++ b/app/Controllers/TeamController.php
@@ -0,0 +1,29 @@
+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);
+ }
+}
diff --git a/app/Controllers/TournamentController.php b/app/Controllers/TournamentController.php
new file mode 100644
index 0000000..aebc0b9
--- /dev/null
+++ b/app/Controllers/TournamentController.php
@@ -0,0 +1,37 @@
+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);
+ }
+}
diff --git a/app/Controllers/UploadController.php b/app/Controllers/UploadController.php
new file mode 100644
index 0000000..0f22702
--- /dev/null
+++ b/app/Controllers/UploadController.php
@@ -0,0 +1,30 @@
+ '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);
+ }
+}
diff --git a/app/Controllers/UserController.php b/app/Controllers/UserController.php
new file mode 100644
index 0000000..50c3116
--- /dev/null
+++ b/app/Controllers/UserController.php
@@ -0,0 +1,43 @@
+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);
+ }
+}
diff --git a/app/Core/Auth.php b/app/Core/Auth.php
new file mode 100644
index 0000000..639f927
--- /dev/null
+++ b/app/Core/Auth.php
@@ -0,0 +1,28 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ]);
+
+ return self::$pdo;
+ }
+}
diff --git a/app/Core/Jwt.php b/app/Core/Jwt.php
new file mode 100644
index 0000000..c0e53ae
--- /dev/null
+++ b/app/Core/Jwt.php
@@ -0,0 +1,55 @@
+ '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, '-_', '+/')) ?: '';
+ }
+}
diff --git a/app/Core/Log.php b/app/Core/Log.php
new file mode 100644
index 0000000..2757380
--- /dev/null
+++ b/app/Core/Log.php
@@ -0,0 +1,12 @@
+ $message, 'errors' => $errors], $status);
+ }
+}
diff --git a/app/Core/Router.php b/app/Core/Router.php
new file mode 100644
index 0000000..c0ed4e5
--- /dev/null
+++ b/app/Core/Router.php
@@ -0,0 +1,31 @@
+[^/]+)', $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);
+ }
+}
diff --git a/app/Core/Validator.php b/app/Core/Validator.php
new file mode 100644
index 0000000..574d6c9
--- /dev/null
+++ b/app/Core/Validator.php
@@ -0,0 +1,18 @@
+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],
+ ];
+ }
+}
diff --git a/app/Repositories/MatchRepository.php b/app/Repositories/MatchRepository.php
new file mode 100644
index 0000000..3246f79
--- /dev/null
+++ b/app/Repositories/MatchRepository.php
@@ -0,0 +1,53 @@
+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;
+ }
+}
diff --git a/app/Repositories/PlayerRepository.php b/app/Repositories/PlayerRepository.php
new file mode 100644
index 0000000..a9125cc
--- /dev/null
+++ b/app/Repositories/PlayerRepository.php
@@ -0,0 +1,42 @@
+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();
+ }
+}
diff --git a/app/Repositories/SheetTemplateRepository.php b/app/Repositories/SheetTemplateRepository.php
new file mode 100644
index 0000000..f7214c7
--- /dev/null
+++ b/app/Repositories/SheetTemplateRepository.php
@@ -0,0 +1,110 @@
+ 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 : [];
+ }
+}
diff --git a/app/Repositories/TeamRepository.php b/app/Repositories/TeamRepository.php
new file mode 100644
index 0000000..657d085
--- /dev/null
+++ b/app/Repositories/TeamRepository.php
@@ -0,0 +1,51 @@
+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;
+ }
+}
diff --git a/app/Repositories/TournamentRepository.php b/app/Repositories/TournamentRepository.php
new file mode 100644
index 0000000..aaf5664
--- /dev/null
+++ b/app/Repositories/TournamentRepository.php
@@ -0,0 +1,51 @@
+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;
+ }
+}
diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php
new file mode 100644
index 0000000..2aa5b78
--- /dev/null
+++ b/app/Repositories/UserRepository.php
@@ -0,0 +1,65 @@
+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;
+ }
+}
diff --git a/app/Services/AdvancedScoreSheetService.php b/app/Services/AdvancedScoreSheetService.php
new file mode 100644
index 0000000..a7727f7
--- /dev/null
+++ b/app/Services/AdvancedScoreSheetService.php
@@ -0,0 +1,332 @@
+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();
+ }
+}
diff --git a/app/Services/DemoScoresheetSeeder.php b/app/Services/DemoScoresheetSeeder.php
new file mode 100644
index 0000000..c313f4e
--- /dev/null
+++ b/app/Services/DemoScoresheetSeeder.php
@@ -0,0 +1,191 @@
+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]);
+ }
+ }
+}
diff --git a/app/Services/FixtureService.php b/app/Services/FixtureService.php
new file mode 100644
index 0000000..3f78135
--- /dev/null
+++ b/app/Services/FixtureService.php
@@ -0,0 +1,49 @@
+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();
+ }
+}
diff --git a/app/Services/ScoreSheetService.php b/app/Services/ScoreSheetService.php
new file mode 100644
index 0000000..e83114c
--- /dev/null
+++ b/app/Services/ScoreSheetService.php
@@ -0,0 +1,261 @@
+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();
+ }
+}
diff --git a/bin/websocket-server.php b/bin/websocket-server.php
new file mode 100644
index 0000000..9b652f5
--- /dev/null
+++ b/bin/websocket-server.php
@@ -0,0 +1,88 @@
+ $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;
+}
diff --git a/config/app.php b/config/app.php
new file mode 100644
index 0000000..4f08c43
--- /dev/null
+++ b/config/app.php
@@ -0,0 +1,18 @@
+ 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',
+ ],
+];
diff --git a/config/templates/ltv26_scoresheet.php b/config/templates/ltv26_scoresheet.php
new file mode 100644
index 0000000..c5f4104
--- /dev/null
+++ b/config/templates/ltv26_scoresheet.php
@@ -0,0 +1,43 @@
+ '/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],
+ ],
+];
diff --git a/database/fix-mojibake.sql b/database/fix-mojibake.sql
new file mode 100644
index 0000000..c6a6113
--- /dev/null
+++ b/database/fix-mojibake.sql
@@ -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');
diff --git a/database/migrations/20260519_ltv26_template_fields.sql b/database/migrations/20260519_ltv26_template_fields.sql
new file mode 100644
index 0000000..cd770f8
--- /dev/null
+++ b/database/migrations/20260519_ltv26_template_fields.sql
@@ -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;
diff --git a/database/migrations/20260519_scoresheet_advanced.sql b/database/migrations/20260519_scoresheet_advanced.sql
new file mode 100644
index 0000000..aed7e10
--- /dev/null
+++ b/database/migrations/20260519_scoresheet_advanced.sql
@@ -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;
diff --git a/database/migrations/20260519_sheet_templates.sql b/database/migrations/20260519_sheet_templates.sql
new file mode 100644
index 0000000..386b17a
--- /dev/null
+++ b/database/migrations/20260519_sheet_templates.sql
@@ -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;
diff --git a/database/schema.sql b/database/schema.sql
new file mode 100644
index 0000000..1bd5586
--- /dev/null
+++ b/database/schema.sql
@@ -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;
diff --git a/database/seeds.sql b/database/seeds.sql
new file mode 100644
index 0000000..fd87748
--- /dev/null
+++ b/database/seeds.sql
@@ -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);
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..1ed3267
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -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:
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..e16f96f
--- /dev/null
+++ b/docker-compose.yml
@@ -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:
diff --git a/docker/apache.conf b/docker/apache.conf
new file mode 100644
index 0000000..e2b5321
--- /dev/null
+++ b/docker/apache.conf
@@ -0,0 +1,9 @@
+
+ DocumentRoot /var/www/html/public
+
+ AllowOverride All
+ Require all granted
+
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..011ab39
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1025 @@
+{
+ "name": "torneos",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "tailwindcss": "^3.4.17"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4000bdd
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
diff --git a/public/.htaccess b/public/.htaccess
new file mode 100644
index 0000000..932d010
--- /dev/null
+++ b/public/.htaccess
@@ -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]
diff --git a/public/app.html b/public/app.html
new file mode 100644
index 0000000..8133bf5
--- /dev/null
+++ b/public/app.html
@@ -0,0 +1,117 @@
+
+
+
+
+
+ Volley Manager
+
+
+
+
+
+
+
+
+
+
+
+
+
Volley Manager
+
Torneos y planilla
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/app.js b/public/assets/app.js
new file mode 100644
index 0000000..7edd293
--- /dev/null
+++ b/public/assets/app.js
@@ -0,0 +1,1334 @@
+const state = {
+ token: localStorage.getItem('token'),
+ user: localStorage.getItem('token') ? JSON.parse(localStorage.getItem('user') || 'null') : null,
+ tournaments: [],
+ route: 'public',
+ ws: null,
+ scoreMatchId: null,
+ scoreMode: localStorage.getItem('scoreMode') || 'expert',
+ sidebarCollapsed: localStorage.getItem('sidebarCollapsed') === '1',
+ authValidated: false
+};
+
+const $ = (selector) => document.querySelector(selector);
+const view = $('#view');
+
+function toast(message, kind = 'ok') {
+ const item = document.createElement('div');
+ item.className = `rounded px-4 py-3 text-sm font-bold shadow ${kind === 'error' ? 'bg-red-600' : 'bg-zinc-900'} text-white`;
+ item.textContent = message;
+ $('#toast').appendChild(item);
+ setTimeout(() => item.remove(), 3200);
+}
+
+async function api(path, options = {}) {
+ const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
+ if (state.token) headers.Authorization = `Bearer ${state.token}`;
+ const res = await fetch(path, { ...options, headers });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ const error = new Error(data.message || 'Error de API');
+ error.status = res.status;
+ throw error;
+ }
+ return data;
+}
+
+function selectedTournamentId() {
+ return Number($('#tournamentSelect').value || state.tournaments[0]?.id || 1);
+}
+
+function applySidebarState() {
+ document.body.classList.toggle('sidebar-collapsed', state.sidebarCollapsed);
+ $('#sidebarToggle')?.setAttribute('title', state.sidebarCollapsed ? 'Expandir menu' : 'Minimizar menu');
+}
+
+function openMobileMenu() {
+ document.body.classList.add('mobile-menu-open');
+}
+
+function closeMobileMenu() {
+ document.body.classList.remove('mobile-menu-open');
+}
+
+async function loadBase() {
+ const tournaments = await api('/api/tournaments');
+ state.tournaments = tournaments.data || [];
+ $('#tournamentSelect').innerHTML = state.tournaments.map(t => ``).join('');
+ updateSession();
+}
+
+async function validateStoredSession() {
+ if (!state.token) {
+ clearSession();
+ state.authValidated = true;
+ return;
+ }
+
+ try {
+ const res = await api('/api/auth/me');
+ state.user = res.user;
+ localStorage.setItem('user', JSON.stringify(res.user));
+ } catch (err) {
+ if (err.status === 401 || err.status === 403) {
+ clearSession();
+ }
+ } finally {
+ state.authValidated = true;
+ updateSession();
+ }
+}
+
+function clearSession() {
+ state.token = null;
+ state.user = null;
+ localStorage.removeItem('token');
+ localStorage.removeItem('user');
+}
+
+function currentRole() {
+ return String(state.user?.role || '').trim().toLowerCase();
+}
+
+function isAdmin() {
+ return Boolean(state.token && state.user && currentRole() === 'admin');
+}
+
+function updateSession() {
+ const role = currentRole();
+ $('#sessionInfo').textContent = state.user ? `${state.user.name} - ${role}` : 'Modo publico';
+ $('#loginBtn').setAttribute('title', state.user ? `Sesion: ${state.user.name}` : 'Ingresar');
+ $('#sideLoginBtn').textContent = state.user ? 'Ver sesion' : 'Ingresar';
+ $('#sessionUser').textContent = state.user ? state.user.name : 'Invitado';
+ $('#sessionRole').textContent = state.user ? `${state.user.email} - ${role}` : 'Modo publico';
+}
+
+function openModal({ eyebrow = '', title, subtitle = '', body, footer = '' }) {
+ $('#modalEyebrow').textContent = eyebrow;
+ $('#modalTitle').textContent = title;
+ $('#modalSubtitle').textContent = subtitle;
+ $('#modalBody').innerHTML = body;
+ $('#modalFooter').innerHTML = footer;
+ $('#modalFooter').classList.toggle('hidden', !footer);
+ $('#modalRoot').classList.remove('hidden');
+ $('#modalRoot').setAttribute('aria-hidden', 'false');
+ document.body.style.overflow = 'hidden';
+}
+
+function closeModal() {
+ $('#modalRoot').classList.add('hidden');
+ $('#modalRoot').setAttribute('aria-hidden', 'true');
+ document.body.style.overflow = '';
+}
+
+function openLoginModal() {
+ if (state.user) {
+ openModal({
+ eyebrow: 'Sesion activa',
+ title: state.user.name,
+ subtitle: `${state.user.email} - ${state.user.role}`,
+ body: `
+
Ya estas autenticado en el sistema.
+
+
`
+ });
+ $('#logoutBtn').onclick = () => {
+ clearSession();
+ updateSession();
+ closeModal();
+ toast('Sesion cerrada');
+ render();
+ };
+ return;
+ }
+
+ openModal({
+ eyebrow: 'Acceso seguro',
+ title: 'Ingresar',
+ subtitle: 'Usa tu usuario administrador o delegado para gestionar torneos.',
+ body: `
+
JWT activo
+
Credenciales de prueba: admin@volley.test / password
+
+ `
+ });
+
+ const loginForm = $('#modalBody').querySelector('#loginForm');
+ if (loginForm) {
+ loginForm.onsubmit = handleLogin;
+ }
+}
+
+async function handleLogin(e) {
+ e.preventDefault();
+ const data = Object.fromEntries(new FormData(e.target));
+ const res = await api('/api/auth/login', { method: 'POST', body: JSON.stringify(data) });
+ state.token = res.token;
+ state.user = res.user;
+ state.authValidated = true;
+ localStorage.setItem('token', res.token);
+ localStorage.setItem('user', JSON.stringify(res.user));
+ updateSession();
+ closeModal();
+ toast('Sesion iniciada');
+ render();
+}
+
+function setRoute(route) {
+ state.route = route;
+ syncActiveRoute();
+ closeMobileMenu();
+ render().catch(err => toast(err.message, 'error'));
+}
+
+function syncActiveRoute() {
+ document.querySelectorAll('.nav-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.route === state.route));
+}
+
+function toggleSidebar() {
+ state.sidebarCollapsed = !state.sidebarCollapsed;
+ localStorage.setItem('sidebarCollapsed', state.sidebarCollapsed ? '1' : '0');
+ applySidebarState();
+}
+
+async function render() {
+ try {
+ syncActiveRoute();
+ if (state.route === 'admin') return renderAdmin();
+ if (state.route === 'score') return renderScore();
+ if (state.route === 'team-link') return renderTeamLink();
+ return renderPublic();
+ } catch (err) {
+ toast(err.message, 'error');
+ }
+}
+
+async function renderPublic() {
+ const id = selectedTournamentId();
+ const [standings, matches, stats] = await Promise.all([
+ api(`/api/tournaments/${id}/standings`),
+ api(`/api/matches?tournament_id=${id}&per_page=50`),
+ api(`/api/tournaments/${id}/stats`)
+ ]);
+ view.innerHTML = `
+
+
+ Resultados, fixture y tabla
+ Vista publica responsive con datos actualizados desde la planilla electronica.
+
+
+ Tabla de posiciones
+ ${table(['Equipo','PJ','G','P','SF','SC','Dif','Pts'], standings.map(r => [r.name, r.played, r.won, r.lost, r.sets_for, r.sets_against, r.sets_for - r.sets_against, r.points]))}
+
+
+ Calendario
+ ${table(['Fecha','Local','Visitante','Sets','Estado'], (matches.data || []).map(m => [fmt(m.scheduled_at), m.home_team, m.away_team, `${m.home_sets}-${m.away_sets}`, m.status]))}
+
+
+ Ranking de jugadores
+ ${table(['Jugador','Equipo','Puntos','Aces','Bloqueos','MVP'], (stats.players || []).map(p => [p.player_name, p.team_name, p.points || 0, p.aces || 0, p.blocks || 0, p.mvp || 0]))}
+
+
`;
+}
+
+async function renderAdmin() {
+ if (!state.authValidated || state.token) {
+ await validateStoredSession();
+ }
+
+ if (!isAdmin()) {
+ view.innerHTML = `
+ Acceso administrador
+ Para crear torneos, equipos y partidos tenes que iniciar sesion con un usuario administrador.
+ Estado actual: ${state.user ? `${state.user.email} - ${currentRole() || 'sin rol'}` : 'sin sesion valida'}
+
+ `;
+ $('#adminLoginCta').onclick = openLoginModal;
+ setTimeout(openLoginModal, 150);
+ return;
+ }
+
+ const id = selectedTournamentId();
+ let teams;
+ let players;
+ let matches;
+ let users;
+ let templates;
+ try {
+ [teams, players, matches, users, templates] = await Promise.all([
+ api(`/api/teams?tournament_id=${id}&per_page=50`),
+ api('/api/players?per_page=50'),
+ api(`/api/matches?tournament_id=${id}&per_page=50`),
+ api('/api/users?per_page=50'),
+ api(`/api/sheet-templates?per_page=50&tournament_id=${id}`)
+ ]);
+ } catch (err) {
+ if (err.status === 401) {
+ clearSession();
+ state.authValidated = true;
+ updateSession();
+ toast('La sesion expiro. Inicia sesion nuevamente.', 'error');
+ return renderAdmin();
+ }
+ throw err;
+ }
+ view.innerHTML = `
+
+
+
+
+
Dashboard administrativo
+
Gestion operativa del torneo seleccionado.
+
+
+ ${metricCard('Equipos', teams.data?.length || 0)}
+ ${metricCard('Jugadores', players.data?.length || 0)}
+ ${metricCard('Partidos', matches.data?.length || 0)}
+ ${metricCard('Usuarios', users.data?.length || 0)}
+
+
+
+
+
+
+
+
+
+
+
+
+ Equipos
${table(['Equipo','DT','Link ficha'], (teams.data || []).map(t => [t.name, t.coach_name || '-', `/registro/${t.registration_token}`]))}
+ Usuarios
${userTable(users.data || [])}
+ Plantillas de planilla
${templateTable(templates.data || [])}
+ Jugadores
${table(['Jugador','Equipo','DNI','Nro','Posicion'], (players.data || []).map(p => [`${p.first_name} ${p.last_name}`, p.team_name, p.document_id, p.jersey_number || '-', p.position || '-']))}
+ Partidos
${table(['ID','Fecha','Local','Visitante','Estado'], (matches.data || []).map(m => [m.id, fmt(m.scheduled_at), m.home_team, m.away_team, m.status]))}
+
+
`;
+ bindAdminActions(teams.data || [], users.data || [], templates.data || []);
+}
+
+function bindAdminActions(teams, users, templates) {
+ document.querySelector('[data-admin-modal="user"]').onclick = () => openUserModal();
+ document.querySelector('[data-admin-modal="tournament"]').onclick = () => openTournamentModal();
+ document.querySelector('[data-admin-modal="team"]').onclick = () => openTeamModal();
+ document.querySelector('[data-admin-modal="match"]').onclick = () => openMatchModal(teams);
+ document.querySelector('[data-admin-modal="template"]').onclick = () => openTemplateModal();
+ $('#demoScoresheetBtn').onclick = openDemoScoresheetModal;
+ document.querySelectorAll('[data-edit-user]').forEach(btn => {
+ btn.onclick = () => openUserModal(users.find(user => Number(user.id) === Number(btn.dataset.editUser)));
+ });
+ document.querySelectorAll('[data-toggle-user]').forEach(btn => {
+ btn.onclick = () => toggleUser(Number(btn.dataset.toggleUser), Number(btn.dataset.active) ? 0 : 1);
+ });
+ document.querySelectorAll('[data-assign-template]').forEach(btn => {
+ btn.onclick = () => openAssignTemplateModal(Number(btn.dataset.assignTemplate));
+ });
+ document.querySelectorAll('[data-edit-template]').forEach(btn => {
+ btn.onclick = () => openTemplateDesigner(Number(btn.dataset.editTemplate));
+ });
+}
+
+function openUserModal(user = null) {
+ const isEdit = Boolean(user);
+ openModal({
+ eyebrow: 'Seguridad',
+ title: isEdit ? 'Editar usuario' : 'Crear usuario',
+ subtitle: 'Administra accesos para administradores y delegados.',
+ body: ``
+ });
+
+ $('#userForm').onsubmit = submitForm(isEdit ? `/api/users/${user.id}` : '/api/users', data => {
+ if (isEdit && !data.password) delete data.password;
+ return data;
+ }, true, isEdit ? 'PUT' : 'POST');
+}
+
+async function toggleUser(id, active) {
+ await api(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ active }) });
+ toast(active ? 'Usuario activado' : 'Usuario desactivado');
+ render();
+}
+
+function openTemplateModal() {
+ const defaultConfig = JSON.stringify({
+ fields: {},
+ result_rows: [],
+ result_columns: {},
+ home_players: {},
+ away_players: {}
+ }, null, 2);
+ openModal({
+ eyebrow: 'Plantillas',
+ title: 'Crear plantilla',
+ subtitle: 'Subi la imagen a public/templates y defini coordenadas JSON.',
+ body: ``
+ });
+ $('#templateForm').onsubmit = submitForm('/api/sheet-templates', data => ({ ...data, config_json: JSON.parse(data.config_json) }), true);
+}
+
+function openAssignTemplateModal(templateId) {
+ openModal({
+ eyebrow: 'Plantillas',
+ title: 'Asignar plantilla',
+ subtitle: 'Selecciona el torneo que usara esta planilla por defecto.',
+ body: ``
+ });
+ $('#assignTemplateForm').onsubmit = async (event) => {
+ event.preventDefault();
+ const data = Object.fromEntries(new FormData(event.target));
+ await assignTemplateToTournament(templateId, Number(data.tournament_id));
+ closeModal();
+ };
+}
+
+async function assignTemplateToTournament(templateId, tournamentId) {
+ try {
+ await api(`/api/tournaments/${tournamentId}/sheet-template`, { method: 'POST', body: JSON.stringify({ sheet_template_id: templateId }) });
+ toast('Plantilla asignada al torneo');
+ await renderAdmin();
+ } catch (err) {
+ toast(err.message, 'error');
+ await renderAdmin();
+ }
+}
+
+function openDemoScoresheetModal() {
+ openModal({
+ eyebrow: 'Demo',
+ title: 'Generar demo de planilla',
+ subtitle: 'Crea equipos, jugadores, partido, sets, libero, rotaciones y firma demo para probar la plantilla.',
+ body: ``
+ });
+ $('#demoScoresheetForm').onsubmit = async (event) => {
+ event.preventDefault();
+ const data = Object.fromEntries(new FormData(event.target));
+ const res = await api(`/api/tournaments/${data.tournament_id}/demo-scoresheet-data`, { method: 'POST', body: JSON.stringify({ force: Number(data.force) === 1 }) });
+ closeModal();
+ toast(res.created ? `Demo creado. Partido #${res.match_id}` : res.message);
+ await renderAdmin();
+ };
+}
+
+async function openTemplateDesigner(templateId) {
+ const template = await api(`/api/sheet-templates/${templateId}/effective`);
+ if (!template) return;
+ const config = safeJson(template.effective_config_json || template.config_json, {});
+ state.designer = {
+ template,
+ config,
+ selectedKey: null,
+ selectedKind: 'field',
+ drag: null
+ };
+ renderTemplateDesigner();
+}
+
+function renderTemplateDesigner() {
+ const template = state.designer.template;
+ const config = state.designer.config;
+ view.innerHTML = `
+
+
+
+
Editor de plantilla
+
${template.name} - ${template.page_width}x${template.page_height}
+
+
+
+
+
+
+
+
+
+
+

+ ${designerFields(config)}
+ ${designerPlayerBlock(config.home_players, 'home_players', 'Jugadores A')}
+ ${designerPlayerBlock(config.away_players, 'away_players', 'Jugadores B')}
+ ${designerResultBlock(config)}
+
+
+
+
+
`;
+ bindTemplateDesigner();
+}
+
+function designerFields(config) {
+ return Object.entries(config.fields || {}).map(([key, box]) => {
+ const left = percent(box.x, state.designer.template.page_width);
+ const top = percent(box.y, state.designer.template.page_height);
+ const width = percent(box.w || 120, state.designer.template.page_width);
+ const active = state.designer.selectedKey === key ? 'active' : '';
+ return `${fieldPreview(key)}
`;
+ }).join('');
+}
+
+function designerPlayerBlock(config, key, label) {
+ if (!config?.x) return '';
+ const left = percent(config.x, state.designer.template.page_width);
+ const top = percent(config.y, state.designer.template.page_height);
+ const width = percent((config.number_w || 30) + (config.name_w || 120), state.designer.template.page_width);
+ const height = percent((config.row_h || 20) * 14, state.designer.template.page_height);
+ const active = state.designer.selectedKey === key ? 'active' : '';
+ return `${label}
`;
+}
+
+function designerResultBlock(config) {
+ if (!config?.result_rows?.length || !config?.result_columns?.set) return '';
+ const row = config.result_rows[0];
+ const col = config.result_columns.home_t || config.result_columns.set;
+ const lastRow = config.result_rows[config.result_rows.length - 1];
+ const left = percent(col.x, state.designer.template.page_width);
+ const top = percent(row.y, state.designer.template.page_height);
+ const width = percent(300, state.designer.template.page_width);
+ const height = percent((lastRow.y - row.y) + 24, state.designer.template.page_height);
+ const active = state.designer.selectedKey === 'result' ? 'active' : '';
+ return `Resultado
`;
+}
+
+function fieldPropsPanel() {
+ const key = state.designer?.selectedKey;
+ if (!key) return 'Selecciona un campo del lienzo.
';
+ if (state.designer.selectedKind === 'block') {
+ return blockPropsPanel(key);
+ }
+ const box = state.designer.config.fields[key];
+ return `
+ ${field('Campo', ``)}
+ ${field('X', input('prop_x','x','number', box.x || 0))}
+ ${field('Y', input('prop_y','y','number', box.y || 0))}
+ ${field('Ancho', input('prop_w','w','number', box.w || 120))}
+ ${field('Fuente', input('prop_size','size','number', box.size || 12))}
+ ${field('Alineacion', ``)}
+ ${field('Alineacion vertical', ``)}
+
+
`;
+}
+
+function blockPropsPanel(key) {
+ const block = getDesignerBlock(key);
+ if (!block) return 'Bloque no disponible.
';
+ return `
+ ${field('Bloque', ``)}
+ ${field('X', input('block_x','x','number', block.x || 0))}
+ ${field('Y', input('block_y','y','number', block.y || 0))}
+ ${key === 'result' ? field('Separacion filas', input('block_row_h','row_h','number', resultRowHeight())) : field('Alto fila', input('block_row_h','row_h','number', block.row_h || 22))}
+ ${key === 'result' ? '' : field('Ancho numero', input('block_number_w','number_w','number', block.number_w || 32))}
+ ${key === 'result' ? '' : field('Ancho nombre', input('block_name_w','name_w','number', block.name_w || 124))}
+ ${field('Fuente', input('block_size','size','number', block.size || 10))}
+ ${field('Alineacion vertical', ``)}
+
+
`;
+}
+
+function bindTemplateDesigner() {
+ $('#backAdminBtn').onclick = () => {
+ document.onkeydown = null;
+ renderAdmin();
+ };
+ $('#saveTemplateDesignBtn').onclick = saveTemplateDesign;
+ document.querySelectorAll('[data-add-field]').forEach(btn => btn.onclick = () => addTemplateField(btn.dataset.addField));
+ document.querySelectorAll('[data-select-block]').forEach(btn => btn.onclick = () => selectTemplateBlock(btn.dataset.selectBlock));
+ document.querySelectorAll('[data-field-key]').forEach(el => {
+ el.onclick = () => selectTemplateField(el.dataset.fieldKey);
+ el.onpointerdown = (event) => startFieldDrag(event, el.dataset.fieldKey);
+ });
+ document.querySelectorAll('[data-block-key]').forEach(el => {
+ el.onclick = () => selectTemplateBlock(el.dataset.blockKey);
+ el.onpointerdown = (event) => startBlockDrag(event, el.dataset.blockKey);
+ });
+ const props = $('#fieldProps');
+ props.querySelectorAll('input, select').forEach(inputEl => inputEl.oninput = state.designer.selectedKind === 'block' ? updateSelectedBlockFromProps : updateSelectedFieldFromProps);
+ const deleteBtn = $('#deleteFieldBtn');
+ if (deleteBtn) deleteBtn.onclick = deleteSelectedField;
+ const deleteBlockBtn = $('#deleteBlockBtn');
+ if (deleteBlockBtn) deleteBlockBtn.onclick = deleteSelectedBlock;
+ document.onkeydown = handleTemplateKeydown;
+}
+
+function addTemplateField(key) {
+ if (['home_players', 'away_players', 'result'].includes(key)) {
+ addTemplateBlock(key);
+ return;
+ }
+ state.designer.config.fields = state.designer.config.fields || {};
+ state.designer.config.fields[key] = state.designer.config.fields[key] || { x: 40, y: 40, w: 160, size: 12, align: 'left' };
+ state.designer.selectedKey = key;
+ state.designer.selectedKind = 'field';
+ renderTemplateDesigner();
+}
+
+function selectTemplateField(key) {
+ state.designer.selectedKey = key;
+ state.designer.selectedKind = 'field';
+ renderTemplateDesigner();
+ focusTemplateCanvas();
+}
+
+function selectTemplateBlock(key) {
+ if (!getDesignerBlock(key)) addTemplateBlock(key);
+ state.designer.selectedKey = key;
+ state.designer.selectedKind = 'block';
+ renderTemplateDesigner();
+ focusTemplateCanvas();
+}
+
+function addTemplateBlock(key) {
+ if (key === 'home_players') state.designer.config.home_players = state.designer.config.home_players || { x: 1081, y: 548, row_h: 22, number_w: 32, name_w: 124, size: 10 };
+ if (key === 'away_players') state.designer.config.away_players = state.designer.config.away_players || { x: 1238, y: 548, row_h: 22, number_w: 32, name_w: 124, size: 10 };
+ if (key === 'result') {
+ state.designer.config.result_rows = state.designer.config.result_rows || [{ y: 790 }, { y: 816 }, { y: 842 }, { y: 867 }, { y: 893 }];
+ state.designer.config.result_columns = state.designer.config.result_columns || {
+ home_pt: { x: 886, w: 34 },
+ set: { x: 921, w: 70 },
+ away_pt: { x: 992, w: 34 }
+ };
+ }
+ state.designer.selectedKey = key;
+ state.designer.selectedKind = 'block';
+}
+
+function startFieldDrag(event, key) {
+ event.preventDefault();
+ const canvas = $('#templateCanvas');
+ const rect = canvas.getBoundingClientRect();
+ const box = state.designer.config.fields[key];
+ state.designer.selectedKey = key;
+ state.designer.drag = {
+ key,
+ startX: event.clientX,
+ startY: event.clientY,
+ originX: Number(box.x || 0),
+ originY: Number(box.y || 0),
+ scaleX: state.designer.template.page_width / rect.width,
+ scaleY: state.designer.template.page_height / rect.height
+ };
+ window.onpointermove = moveFieldDrag;
+ window.onpointerup = stopFieldDrag;
+}
+
+function startBlockDrag(event, key) {
+ event.preventDefault();
+ const canvas = $('#templateCanvas');
+ const rect = canvas.getBoundingClientRect();
+ const box = getDesignerBlock(key);
+ state.designer.selectedKey = key;
+ state.designer.selectedKind = 'block';
+ state.designer.drag = {
+ key,
+ kind: 'block',
+ startX: event.clientX,
+ startY: event.clientY,
+ originX: Number(box.x || 0),
+ originY: Number(box.y || 0),
+ resultOriginColumns: key === 'result' ? clone(state.designer.config.result_columns || {}) : null,
+ scaleX: state.designer.template.page_width / rect.width,
+ scaleY: state.designer.template.page_height / rect.height
+ };
+ window.onpointermove = moveFieldDrag;
+ window.onpointerup = stopFieldDrag;
+}
+
+function moveFieldDrag(event) {
+ const drag = state.designer.drag;
+ if (!drag) return;
+ const box = drag.kind === 'block' ? getDesignerBlock(drag.key) : state.designer.config.fields[drag.key];
+ const nextX = Math.max(0, Math.round(drag.originX + ((event.clientX - drag.startX) * drag.scaleX)));
+ box.x = nextX;
+ box.y = Math.max(0, Math.round(drag.originY + ((event.clientY - drag.startY) * drag.scaleY)));
+ if (drag.kind === 'block' && drag.key === 'result') {
+ moveResultRows(box.y);
+ moveResultColumns(drag.resultOriginColumns, nextX - drag.originX);
+ }
+ const selector = drag.kind === 'block' ? `[data-block-key="${drag.key}"]` : `[data-field-key="${drag.key}"]`;
+ const el = document.querySelector(selector);
+ if (el) {
+ el.style.left = `${percent(box.x, state.designer.template.page_width)}%`;
+ el.style.top = `${percent(box.y, state.designer.template.page_height)}%`;
+ }
+}
+
+function stopFieldDrag() {
+ window.onpointermove = null;
+ window.onpointerup = null;
+ state.designer.drag = null;
+ renderTemplateDesigner();
+}
+
+function handleTemplateKeydown(event) {
+ if (!state.designer || !state.designer.selectedKey) return;
+ const active = document.activeElement;
+ if (active && ['INPUT', 'SELECT', 'TEXTAREA'].includes(active.tagName)) return;
+ if (['Delete', 'Backspace'].includes(event.key)) {
+ event.preventDefault();
+ if (state.designer.selectedKind === 'block') deleteSelectedBlock();
+ else deleteSelectedField();
+ return;
+ }
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
+ event.preventDefault();
+ const step = event.shiftKey ? 10 : 1;
+ const target = state.designer.selectedKind === 'block'
+ ? getDesignerBlock(state.designer.selectedKey)
+ : state.designer.config.fields[state.designer.selectedKey];
+ if (!target) return;
+ if (event.key === 'ArrowLeft') target.x = Math.max(0, Number(target.x || 0) - step);
+ if (event.key === 'ArrowRight') target.x = Number(target.x || 0) + step;
+ if (event.key === 'ArrowUp') target.y = Math.max(0, Number(target.y || 0) - step);
+ if (event.key === 'ArrowDown') target.y = Number(target.y || 0) + step;
+ if (state.designer.selectedKind === 'block' && state.designer.selectedKey === 'result') {
+ moveResultRows(target.y);
+ }
+ renderTemplateDesigner();
+ focusTemplateCanvas();
+}
+
+function updateSelectedFieldFromProps() {
+ const key = state.designer.selectedKey;
+ if (!key) return;
+ const box = state.designer.config.fields[key];
+ box.x = Number(document.querySelector('[name="prop_x"]').value || 0);
+ box.y = Number(document.querySelector('[name="prop_y"]').value || 0);
+ box.w = Number(document.querySelector('[name="prop_w"]').value || 120);
+ box.size = Number(document.querySelector('[name="prop_size"]').value || 12);
+ box.align = document.querySelector('[name="prop_align"]').value || 'left';
+ box.valign = document.querySelector('[name="prop_valign"]').value || 'top';
+ renderTemplateDesigner();
+}
+
+function updateSelectedBlockFromProps() {
+ const key = state.designer.selectedKey;
+ const box = getDesignerBlock(key);
+ if (!box) return;
+ box.x = Number(document.querySelector('[name="block_x"]').value || 0);
+ box.y = Number(document.querySelector('[name="block_y"]').value || 0);
+ box.size = Number(document.querySelector('[name="block_size"]').value || 10);
+ box.valign = document.querySelector('[name="block_valign"]').value || 'top';
+ const rowH = Number(document.querySelector('[name="block_row_h"]').value || 22);
+ if (key === 'result') {
+ const cols = state.designer.config.result_columns || {};
+ const oldX = Number((cols.home_t || cols.set || {}).x || box.x);
+ const newX = Number(document.querySelector('[name="block_x"]').value || oldX);
+ moveResultColumns(clone(cols), newX - oldX);
+ moveResultRows(box.y, rowH);
+ } else {
+ box.row_h = rowH;
+ box.number_w = Number(document.querySelector('[name="block_number_w"]').value || 32);
+ box.name_w = Number(document.querySelector('[name="block_name_w"]').value || 124);
+ }
+ renderTemplateDesigner();
+}
+
+function deleteSelectedField() {
+ const key = state.designer.selectedKey;
+ if (!key) return;
+ delete state.designer.config.fields[key];
+ state.designer.selectedKey = null;
+ renderTemplateDesigner();
+}
+
+function deleteSelectedBlock() {
+ const key = state.designer.selectedKey;
+ if (key === 'home_players') delete state.designer.config.home_players;
+ if (key === 'away_players') delete state.designer.config.away_players;
+ if (key === 'result') {
+ delete state.designer.config.result_rows;
+ delete state.designer.config.result_columns;
+ }
+ state.designer.selectedKey = null;
+ state.designer.selectedKind = 'field';
+ renderTemplateDesigner();
+}
+
+function getDesignerBlock(key) {
+ if (key === 'home_players') return state.designer.config.home_players;
+ if (key === 'away_players') return state.designer.config.away_players;
+ if (key === 'result') return state.designer.config.result_columns?.home_t || state.designer.config.result_columns?.set;
+ return null;
+}
+
+function resultRowHeight() {
+ const rows = state.designer.config.result_rows || [];
+ return rows.length > 1 ? Number(rows[1].y) - Number(rows[0].y) : 26;
+}
+
+function moveResultRows(startY, rowH = resultRowHeight()) {
+ (state.designer.config.result_rows || []).forEach((row, index) => {
+ row.y = Number(startY) + (index * Number(rowH));
+ });
+}
+
+function moveResultColumns(originColumns, deltaX) {
+ if (!originColumns) return;
+ Object.entries(originColumns).forEach(([key, col]) => {
+ if (!state.designer.config.result_columns[key]) return;
+ state.designer.config.result_columns[key].x = Math.max(0, Math.round(Number(col.x || 0) + Number(deltaX || 0)));
+ });
+}
+
+function clone(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+async function saveTemplateDesign() {
+ const saved = await api(`/api/sheet-templates/${state.designer.template.id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ config_json: state.designer.config })
+ });
+ const fresh = await api(`/api/sheet-templates/${state.designer.template.id}`);
+ state.designer.template = fresh || saved;
+ state.designer.config = safeJson(state.designer.template.config_json, state.designer.config);
+ toast('Diseno de plantilla guardado');
+ renderTemplateDesigner();
+}
+
+function focusTemplateCanvas() {
+ setTimeout(() => $('#templateCanvas')?.focus?.(), 0);
+}
+
+function availableTemplateFields() {
+ return [
+ 'tournament','date','time','court','home_team','away_team','home_sets','away_sets','winner',
+ 'referee_signature','home_captain','away_captain','home_coach','away_coach','observations',
+ 'home_players','away_players','result'
+ ];
+}
+
+function fieldPreview(key) {
+ const values = {
+ tournament: 'Liga demo',
+ date: '19/05/2026',
+ time: '17:47',
+ court: 'Cancha 1',
+ home_team: 'Demo A',
+ away_team: 'Demo B',
+ home_sets: '3',
+ away_sets: '1',
+ winner: 'Demo A',
+ referee_signature: 'Arbitro Demo',
+ home_captain: 'Capitan A',
+ away_captain: 'Capitan B',
+ home_coach: 'DT A',
+ away_coach: 'DT B',
+ observations: 'Observaciones'
+ };
+ return values[key] || key;
+}
+
+function percent(value, total) {
+ return (Number(value || 0) / Number(total || 1)) * 100;
+}
+
+function alignToJustify(align) {
+ return align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start';
+}
+
+function verticalToAlign(align) {
+ return align === 'middle' ? 'center' : align === 'bottom' ? 'flex-end' : 'flex-start';
+}
+
+function verticalToTransform(align) {
+ return align === 'middle' ? 'translateY(-50%)' : align === 'bottom' ? 'translateY(-100%)' : 'none';
+}
+
+function safeJson(value, fallback) {
+ try {
+ return typeof value === 'string' ? JSON.parse(value) : value;
+ } catch (_) {
+ return fallback;
+ }
+}
+
+function openTournamentModal() {
+ openModal({
+ eyebrow: 'Administracion',
+ title: 'Crear torneo',
+ subtitle: 'Define la categoria, subcategoria y formato competitivo.',
+ body: ``
+ });
+ $('#tournamentForm').onsubmit = submitForm('/api/tournaments', data => data, true);
+}
+
+function openTeamModal() {
+ openModal({
+ eyebrow: 'Administracion',
+ title: 'Crear equipo',
+ subtitle: 'El equipo se agrega al torneo activo y obtiene su link de ficha online.',
+ body: ``
+ });
+ $('#teamForm').onsubmit = submitForm('/api/teams', data => ({ ...data, tournament_id: selectedTournamentId() }), true);
+}
+
+function openMatchModal(teams) {
+ openModal({
+ eyebrow: 'Fixture',
+ title: 'Crear partido',
+ subtitle: 'Programa un encuentro para el torneo seleccionado.',
+ body: ``
+ });
+ $('#matchForm').onsubmit = submitForm('/api/matches', data => ({ ...data, tournament_id: selectedTournamentId() }), true);
+}
+
+async function renderScore() {
+ const id = selectedTournamentId();
+ const matches = await api(`/api/matches?tournament_id=${id}&per_page=50`);
+ const matchList = matches.data || [];
+ const first = matchList.find(match => Number(match.id) === Number(state.scoreMatchId)) || matchList[0];
+ if (!first) {
+ view.innerHTML = 'No hay partidos programados.';
+ return;
+ }
+ state.scoreMatchId = first.id;
+ const [score, advanced, homePlayers, awayPlayers, teamResponse] = await Promise.all([
+ api(`/api/matches/${first.id}/score`),
+ api(`/api/matches/${first.id}/advanced-score`),
+ api(`/api/players?team_id=${first.home_team_id}&per_page=50`),
+ api(`/api/players?team_id=${first.away_team_id}&per_page=50`),
+ api(`/api/teams?tournament_id=${id}&per_page=100`)
+ ]);
+ connectWs(first.id);
+ const currentSet = score.sets.find(s => !s.winner_team_id) || score.sets[score.sets.length - 1] || { set_number: 1, home_points: 0, away_points: 0, side_state: 'normal' };
+ const players = [...(homePlayers.data || []), ...(awayPlayers.data || [])];
+ const fullTeams = teamResponse.data || [];
+ view.innerHTML = `
+
+
+
+
Modo arbitro/anotador
${first.home_team} vs ${first.away_team}
+
+
+
+
+
+
+
+
+
+
+ ${state.scoreMode === 'guided' ? guidedScorePanel(first, currentSet, score, advanced) : expertScorePanel(first, currentSet, score)}
+
+ Rotaciones oficiales
${table(['Set','Equipo','Pos','Jugador','Libero'], advanced.rotations.map(r => [r.set_number, r.team_name, r.position_number, r.player_name, Number(r.is_libero) ? 'Si' : 'No']))}
+ Sustituciones y tiempos
${table(['Tipo','Set','Equipo','Detalle'], [
+ ...advanced.substitutions.map(s => ['Sustitucion', s.set_number, s.team_name, `${s.out_first} ${s.out_last} -> ${s.in_first} ${s.in_last}`]),
+ ...advanced.timeouts.map(t => ['Tiempo', t.set_number, t.team_name, `${t.points_home}-${t.points_away}`])
+ ])}
+ Historial de rallys
${table(['#','Set','Saque','Ganador','Resultado','Marcador'], advanced.rallies.map(r => [r.rally_number, r.set_number, r.serving_team || '-', r.winning_team || '-', r.result_type, `${r.points_home}-${r.points_away}`]))}
+ Auditoria y firmas
${table(['Tipo','Detalle','Fecha'], [
+ ...advanced.signatures.map(s => ['Firma', `${s.signer_name} (${s.role}) ${String(s.signature_hash).slice(0, 10)}...`, fmt(s.signed_at)]),
+ ...advanced.audit.map(a => ['Audit', `${a.action} - ${a.user_name || '-'}`, fmt(a.created_at)])
+ ])}
+
+
Eventos en vivo
${table(['Set','Equipo','Evento','Marcador','Hora'], score.events.map(e => [e.set_number, e.team_name || '-', e.event_type, `${e.points_home}-${e.points_away}`, fmt(e.created_at)]))}
+
`;
+ document.querySelectorAll('[data-team]').forEach(btn => btn.onclick = () => addEvent(first.id, btn.dataset.team, 'point'));
+ document.querySelectorAll('[data-event]').forEach(btn => btn.onclick = () => addEvent(first.id, first.home_team_id, btn.dataset.event));
+ document.querySelectorAll('[data-advanced]').forEach(btn => btn.onclick = () => openAdvancedScoreModal(btn.dataset.advanced, first, players, fullTeams));
+ document.querySelectorAll('[data-score-mode]').forEach(btn => btn.onclick = () => {
+ state.scoreMode = btn.dataset.scoreMode;
+ localStorage.setItem('scoreMode', state.scoreMode);
+ renderScore();
+ });
+ $('#scoreMatch').onchange = (event) => {
+ state.scoreMatchId = Number(event.target.value);
+ renderScore();
+ };
+ $('#exportScoresheetBtn').onclick = () => window.open(`/api/matches/${first.id}/scoresheet/ltv26`, '_blank');
+}
+
+function expertScorePanel(match, currentSet, score) {
+ return `
+ Local
${currentSet.home_points}
${match.home_team}
+ Set ${currentSet.set_number} - Lado ${currentSet.side_state}
${score.sets_won.home} - ${score.sets_won.away}
${eventButtons()}
+ Visitante
${currentSet.away_points}
${match.away_team}
+
+
+ Planilla reglamentaria
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+function guidedScorePanel(match, currentSet, score, advanced) {
+ const rotationCount = advanced.rotations.filter(row => Number(row.set_number) === Number(currentSet.set_number)).length;
+ const hasSignature = advanced.signatures.length > 0;
+ return `
+
+
+
Carga guiada de planilla
+
Pensado para planilleros nuevos o cierre reglamentario.
+
+
Set ${currentSet.set_number} - ${currentSet.home_points}-${currentSet.away_points}
+
+
+ ${guidedStep(1, 'Prepartido', `Confirmar equipos, libero y formacion inicial. Rotaciones cargadas: ${rotationCount}/12.`, [
+ ['rotation', 'Cargar rotacion 1-6'],
+ ['libero', 'Registrar libero']
+ ])}
+ ${guidedStep(2, 'Inicio y control de set', `Sets: ${score.sets_won.home}-${score.sets_won.away}. Lado de cancha: ${currentSet.side_state}.`, [
+ ['rally', 'Registrar rally'],
+ ['timeout', 'Tiempo solicitado'],
+ ['substitution', 'Sustitucion']
+ ])}
+ ${guidedStep(3, 'Incidencias', 'Registrar sanciones, errores administrativos o correcciones con auditoria.', [
+ ['sanction', 'Sancion / tarjeta'],
+ ['rally', 'Rally corregido']
+ ])}
+ ${guidedStep(4, 'Cierre', hasSignature ? 'Planilla firmada digitalmente.' : 'Validar resultado y firmar al cierre.', [
+ ['signature', 'Firma arbitral']
+ ])}
+
+
+
+ Punto local
${match.home_team}
+ Punto visitante
${match.away_team}
+ `;
+}
+
+function guidedStep(number, title, description, actions) {
+ return `
+
${number}
+
+
${title}
+
${description}
+
${actions.map(([type, label]) => ``).join('')}
+
+
`;
+}
+
+function openAdvancedScoreModal(type, match, players, fullTeams = []) {
+ const homeFull = fullTeams.find(team => Number(team.id) === Number(match.home_team_id));
+ const awayFull = fullTeams.find(team => Number(team.id) === Number(match.away_team_id));
+ const teams = [
+ { id: match.home_team_id, name: match.home_team, coach: homeFull?.coach_name || 'DT Local' },
+ { id: match.away_team_id, name: match.away_team, coach: awayFull?.coach_name || 'DT Visitante' }
+ ];
+ const teamSelect = ``;
+ const playerSelect = (name) => ``;
+ const forms = {
+ rotation: {
+ title: 'Rotacion oficial',
+ path: 'rotations',
+ body: `${field('Equipo', teamSelect)}${field('Posicion 1 a 6', '')}${field('Jugador', playerSelect('player_id'))}${field('Libero', '')}`
+ },
+ libero: {
+ title: 'Gestion de libero',
+ path: 'liberos',
+ body: `${field('Equipo', teamSelect)}${field('Jugador libero', playerSelect('player_id'))}${field('Inicial', '')}`
+ },
+ substitution: {
+ title: 'Sustitucion reglamentaria',
+ path: 'substitutions',
+ body: `${field('Equipo', teamSelect)}${field('Sale', playerSelect('player_out_id'))}${field('Entra', playerSelect('player_in_id'))}${field('Motivo', input('reason','Motivo'))}`
+ },
+ timeout: {
+ title: 'Tiempo solicitado',
+ path: 'timeouts',
+ body: `${field('Equipo', teamSelect)}${field('Solicitado por', input('requested_by','DT / capitan'))}`
+ },
+ rally: {
+ title: 'Historial de rally',
+ path: 'rallies',
+ body: `${field('Equipo sacador', ``)}${field('Equipo ganador', ``)}${field('Resultado', '')}${field('Notas', input('notes','Descripcion breve'))}`
+ },
+ signature: {
+ title: 'Firma digital arbitral',
+ path: 'signatures',
+ body: `${field('Nombre del arbitro', input('signer_name','Nombre completo'))}${field('Rol', '')}`
+ },
+ sanction: {
+ title: 'Sancion / tarjeta',
+ path: 'advanced-sanctions',
+ body: `${field('Equipo', ``)}${field('Plantel', ``)}
+
+ ${field('Motivo', input('reason','Motivo'))}`
+ }
+ };
+ const config = forms[type];
+ openModal({
+ eyebrow: 'Planilla electronica',
+ title: config.title,
+ subtitle: 'El registro queda en eventos, historial y auditoria arbitral.',
+ body: ``
+ });
+ if (type === 'sanction') bindSanctionRoster(teams, players);
+ $('#advancedScoreForm').onsubmit = async (event) => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(event.target));
+ if (payload.player_id === 'coach' || payload.player_id === '') {
+ payload.reason = `${payload.player_id === 'coach' ? 'DT - ' : ''}${payload.reason || ''}`.trim();
+ payload.player_id = null;
+ }
+ await api(`/api/matches/${match.id}/${config.path}`, { method: 'POST', body: JSON.stringify(payload) });
+ closeModal();
+ toast('Registro guardado');
+ renderScore();
+ };
+}
+
+function bindSanctionRoster(teams, players) {
+ const teamSelectEl = $('#sanctionTeamSelect');
+ const rosterSelect = $('#sanctionRosterSelect');
+ const refresh = () => {
+ const teamId = Number(teamSelectEl.value);
+ const team = teams.find(item => Number(item.id) === teamId);
+ const roster = players.filter(player => Number(player.team_id) === teamId);
+ rosterSelect.innerHTML = [
+ '',
+ ``,
+ ...roster.map(player => ``)
+ ].join('');
+ };
+ teamSelectEl.onchange = refresh;
+ refresh();
+}
+
+function eventButtons() {
+ return ['serve','ace','block','attack','error','rotation'].map(e => ``).join('');
+}
+
+async function addEvent(matchId, teamId, eventType) {
+ try {
+ await api(`/api/matches/${matchId}/events`, { method: 'POST', body: JSON.stringify({ team_id: Number(teamId), event_type: eventType }) });
+ toast('Evento registrado');
+ renderScore();
+ } catch (err) {
+ toast(err.message, 'error');
+ }
+}
+
+function connectWs(matchId) {
+ if (state.ws) return;
+ try {
+ state.ws = new WebSocket(`ws://${location.hostname}:8081?match_id=${matchId}`);
+ state.ws.onmessage = () => state.route === 'score' && renderScore();
+ } catch (_) {}
+}
+
+function renderTeamLink() {
+ view.innerHTML = `
+ Ficha online de jugador
+ Usa el token del equipo. Ejemplo seed: 11111111111111111111111111111111
+
+ `;
+ $('#linkForm').onsubmit = async (e) => {
+ e.preventDefault();
+ const data = Object.fromEntries(new FormData(e.target));
+ const token = data.token;
+ delete data.token;
+ await api(`/api/team-links/${token}/players`, { method: 'POST', body: JSON.stringify(data) });
+ toast('Ficha registrada');
+ e.target.reset();
+ };
+}
+
+function submitForm(path, mapper, shouldCloseModal = false, method = 'POST') {
+ return async (e) => {
+ e.preventDefault();
+ const payload = mapper(Object.fromEntries(new FormData(e.target)));
+ await api(path, { method, body: JSON.stringify(payload) });
+ toast('Guardado correctamente');
+ if (shouldCloseModal) closeModal();
+ await loadBase();
+ render();
+ };
+}
+
+function userTable(users) {
+ const rows = users.map(user => [
+ `${user.name}
${user.email}
`,
+ user.role,
+ Number(user.active) ? 'Activo' : 'Inactivo',
+ `
+
+
+
`
+ ]);
+ return table(['Usuario','Rol','Estado','Acciones'], rows);
+}
+
+function templateTable(templates) {
+ const rows = templates.map(template => [
+ `${template.name}
${template.code}
`,
+ `${template.page_width}x${template.page_height}`,
+ template.image_path,
+ Number(template.assigned_to_tournament) ? 'Asignada' : (Number(template.active) ? 'Activa' : 'Inactiva'),
+ ``
+ ]);
+ return table(['Plantilla','Tamano','Imagen','Estado','Acciones'], rows);
+}
+
+function table(headers, rows) {
+ return `${headers.map(h => `| ${h} | `).join('')}
${(rows.length ? rows : [['Sin datos']]).map(r => `${r.map(c => `| ${c ?? '-'} | `).join('')}
`).join('')}
`;
+}
+
+function input(name, placeholder, type = 'text', value = '') {
+ return ``;
+}
+
+function field(label, control) {
+ return ``;
+}
+
+function escapeAttr(value) {
+ return String(value ?? '').replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
+}
+
+function escapeHtml(value) {
+ return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
+}
+
+function teamOptions(teams = []) {
+ return teams.map(t => ``).join('');
+}
+
+function metricCard(label, value) {
+ return ``;
+}
+
+function fmt(value) {
+ if (!value) return '-';
+ return new Date(value.replace(' ', 'T')).toLocaleString('es-AR', { dateStyle: 'short', timeStyle: 'short' });
+}
+
+$('#loginBtn').onclick = openLoginModal;
+$('#sideLoginBtn').onclick = openLoginModal;
+$('#sidebarToggle').onclick = toggleSidebar;
+$('#mobileMenuBtn').onclick = openMobileMenu;
+$('#mobileMenuBackdrop').onclick = closeMobileMenu;
+$('#themeBtn').onclick = () => document.documentElement.classList.toggle('dark');
+$('#refreshBtn').onclick = () => render().catch(err => toast(err.message, 'error'));
+$('#fixtureBtn').onclick = async () => {
+ try {
+ await api(`/api/tournaments/${selectedTournamentId()}/fixture`, { method: 'POST', body: JSON.stringify({}) });
+ toast('Fixture generado');
+ await render();
+ } catch (err) {
+ if (err.status === 401) {
+ clearSession();
+ updateSession();
+ openLoginModal();
+ }
+ toast(err.message, 'error');
+ }
+};
+document.querySelectorAll('[data-modal-close]').forEach(el => el.onclick = closeModal);
+document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') closeModal();
+});
+document.querySelectorAll('[data-route]').forEach(btn => btn.onclick = () => setRoute(btn.dataset.route));
+$('#tournamentSelect').onchange = () => render().catch(err => toast(err.message, 'error'));
+
+applySidebarState();
+validateStoredSession()
+ .then(loadBase)
+ .then(render)
+ .catch(err => toast(err.message, 'error'));
diff --git a/public/assets/styles.css b/public/assets/styles.css
new file mode 100644
index 0000000..62164be
--- /dev/null
+++ b/public/assets/styles.css
@@ -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; }
+}
diff --git a/public/assets/tailwind.css b/public/assets/tailwind.css
new file mode 100644
index 0000000..80fbf3d
--- /dev/null
+++ b/public/assets/tailwind.css
@@ -0,0 +1 @@
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.fixed{position:fixed}.right-4{right:1rem}.top-4{top:1rem}.z-50{z-index:50}.mb-4{margin-bottom:1rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.\!block{display:block!important}.block{display:block}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.min-h-\[180px\]{min-height:180px}.min-h-\[70vh\]{min-height:70vh}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[112px\]{min-width:112px}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.border{border-width:1px}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.bg-court{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.uppercase{text-transform:uppercase}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.dark\:border-zinc-800:is(.dark *){--tw-border-opacity:1;border-color:rgb(39 39 42/var(--tw-border-opacity,1))}.dark\:bg-zinc-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.dark\:bg-zinc-950:is(.dark *){--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1))}.dark\:text-zinc-100:is(.dark *){--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1))}.dark\:text-zinc-300:is(.dark *){--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.dark\:text-zinc-400:is(.dark *){--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1280px){.xl\:col-span-2{grid-column:span 2/span 2}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}
\ No newline at end of file
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..0ad7fab
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,98 @@
+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);
+}
diff --git a/public/templates/README.md b/public/templates/README.md
new file mode 100644
index 0000000..6557121
--- /dev/null
+++ b/public/templates/README.md
@@ -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.
diff --git a/public/templates/planilla-ltv-26.png b/public/templates/planilla-ltv-26.png
new file mode 100644
index 0000000..2e92bdf
Binary files /dev/null and b/public/templates/planilla-ltv-26.png differ
diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/resources/css/tailwind.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/scripts/install.ps1 b/scripts/install.ps1
new file mode 100644
index 0000000..afe8afd
--- /dev/null
+++ b/scripts/install.ps1
@@ -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"
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100644
index 0000000..7e428e6
--- /dev/null
+++ b/scripts/install.sh
@@ -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"
diff --git a/scripts/seed_ltv26_template.php b/scripts/seed_ltv26_template.php
new file mode 100644
index 0000000..e709624
--- /dev/null
+++ b/scripts/seed_ltv26_template.php
@@ -0,0 +1,39 @@
+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";
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..ac09587
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,15 @@
+module.exports = {
+ darkMode: 'class',
+ content: [
+ './public/app.html',
+ './public/assets/app.js'
+ ],
+ theme: {
+ extend: {
+ colors: {
+ court: '#16a34a',
+ ink: '#111827'
+ }
+ }
+ }
+};
diff --git a/tests/JwtTest.php b/tests/JwtTest.php
new file mode 100644
index 0000000..dee1f33
--- /dev/null
+++ b/tests/JwtTest.php
@@ -0,0 +1,13 @@
+ 1, 'role' => 'admin']);
+$payload = Jwt::decode($token);
+
+assert($payload['sub'] === 1);
+assert($payload['role'] === 'admin');
+
+echo "JwtTest OK\n";
diff --git a/tests/ScoreRulesTest.php b/tests/ScoreRulesTest.php
new file mode 100644
index 0000000..602daad
--- /dev/null
+++ b/tests/ScoreRulesTest.php
@@ -0,0 +1,15 @@
+= $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";
diff --git a/torneos.code-workspace b/torneos.code-workspace
new file mode 100644
index 0000000..876a149
--- /dev/null
+++ b/torneos.code-workspace
@@ -0,0 +1,8 @@
+{
+ "folders": [
+ {
+ "path": "."
+ }
+ ],
+ "settings": {}
+}
\ No newline at end of file