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.

'; + foreach ($rows as $row) { + echo sprintf('', + htmlspecialchars($row['name']), $row['played'], $row['won'], $row['lost'], $row['sets_for'], $row['sets_against'], $row['points']); + } + echo '
EquipoPJGPSFSCPts
%s%s%s%s%s%s%s
'; + } +} 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' + . '' + . '
' + . 'Planilla' + . 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: `
+ ${field('Nombre', input('name','Nombre', 'text', user?.name || ''))} + ${field('Email', input('email','Email', 'email', user?.email || ''))} + ${field(isEdit ? 'Nueva contrasena opcional' : 'Contrasena', input('password','Contrasena', 'password'))} + ${field('Rol', ``)} + ${field('Estado', ``)} + +
` + }); + + $('#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: `
+ ${field('Codigo', input('code','ej: ltv27'))} + ${field('Nombre', input('name','Nombre de plantilla'))} + ${field('Ruta imagen', input('image_path','/templates/mi-planilla.png'))} + ${field('Ancho px', input('page_width','1418','number'))} + ${field('Alto px', input('page_height','970','number'))} + ${field('Config JSON', ``)} + +
` + }); + $('#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: `
+ ${field('Torneo', ``)} + +
` + }); + $('#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: `
+ ${field('Torneo', ``)} + ${field('Modo', '')} + +
` + }); + $('#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}

+
+
+ + +
+
+
+
+
+
+ ${template.name} + ${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: `
+ ${field('Nombre', input('name','Nombre'))} + ${field('Categoria', '')} + ${field('Subcategoria', input('age_subcategory','Subcategoria'))} + ${field('Formato', '')} + +
` + }); + $('#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: `
+ ${field('Nombre', input('name','Nombre'))} + ${field('DT / responsable', input('coach_name','DT / responsable'))} + +
` + }); + $('#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: `
+ ${field('Local', ``)} + ${field('Visitante', ``)} + ${field('Fecha y hora', '')} + +
` + }); + $('#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', ``)} +
Tarjeta
+ + +
+ ${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: `
${config.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

+
+ ${input('token','Token del equipo')}${input('first_name','Nombre')}${input('last_name','Apellido')}${input('document_id','DNI')}${input('birth_date','Nacimiento', 'date')}${input('jersey_number','Numero', 'number')} + + +
+
`; + $('#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 => ``).join('')}${(rows.length ? rows : [['Sin datos']]).map(r => `${r.map(c => ``).join('')}`).join('')}
${h}
${c ?? '-'}
`; +} + +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 `
+
${value}
+
${label}
+
`; +} + +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