From ed94601e3431103d7f4c33a8b7b18cd36557d295 Mon Sep 17 00:00:00 2001 From: Laucha1312 Date: Thu, 4 Jun 2026 14:47:50 -0300 Subject: [PATCH] Agrego archivos iniciales --- app/AI/Prompts/SystemPromptAdmin.php | 72 + app/AI/Prompts/SystemPromptPublic.php | 51 + app/AI/Tools/CargarPuntajeTool.php | 30 + app/AI/Tools/CrearPartidoTool.php | 43 + app/AI/Tools/EliminarNoticiaTool.php | 29 + app/AI/Tools/EliminarPartidoTool.php | 28 + app/AI/Tools/ListarEquiposTool.php | 33 + app/AI/Tools/ListarEventosTool.php | 40 + app/AI/Tools/ListarTorneosTool.php | 20 + app/AI/Tools/RedactarNoticiaTool.php | 33 + app/Console/Commands/CleanupOldEvents.php | 51 + app/Console/Commands/OptimizeImages.php | 196 ++ app/Console/Commands/PurgeAgentThreads.php | 21 + app/Console/Commands/RecordatorioPartidos.php | 86 + app/Console/Commands/ReporteSemanal.php | 106 ++ .../Controllers/Admin/AdminUserController.php | 115 ++ .../Admin/CarouselItemController.php | 113 ++ .../Controllers/Admin/CategoriaController.php | 74 + .../Controllers/Admin/FixtureController.php | 380 ++++ app/Http/Controllers/Admin/PaseController.php | 129 ++ app/Http/Controllers/AdminController.php | 1667 +++++++++++++++++ app/Http/Controllers/AdminUserController.php | 55 + app/Http/Controllers/AficionadoController.php | 67 + app/Http/Controllers/AuthController.php | 411 ++++ app/Http/Controllers/ClubController.php | 47 + app/Http/Controllers/Controller.php | 8 + .../Controllers/DocumentacionController.php | 221 +++ app/Http/Controllers/EquipoController.php | 57 + app/Http/Controllers/EventoController.php | 84 + .../Controllers/GeniusAgentController.php | 81 + app/Http/Controllers/HomeController.php | 86 + app/Http/Controllers/JugadorController.php | 76 + .../Controllers/JugadorEquipoController.php | 73 + app/Http/Controllers/NoticiaController.php | 61 + .../Controllers/NotificacionController.php | 143 ++ app/Http/Controllers/PanelController.php | 363 ++++ app/Http/Controllers/PromoQrController.php | 52 + app/Http/Controllers/PromocionController.php | 70 + app/Http/Controllers/QrCodeController.php | 57 + app/Http/Controllers/QrDownloadController.php | 129 ++ .../Controllers/SeguimientoController.php | 122 ++ app/Http/Controllers/TorneoController.php | 85 + app/Http/Middleware/SecurityHeaders.php | 40 + app/Mail/QrCodeMail.php | 29 + app/Mail/ResetPasswordMail.php | 27 + app/Mail/WelcomeMail.php | 27 + app/Models/AdminUser.php | 36 + app/Models/Aficionado.php | 38 + app/Models/AgentThread.php | 42 + app/Models/CarouselItem.php | 25 + app/Models/Categoria.php | 19 + app/Models/Club.php | 50 + app/Models/Configuracion.php | 45 + app/Models/Equipo.php | 41 + app/Models/EquipoSeguimiento.php | 28 + app/Models/Evento.php | 96 + app/Models/EventoJugador.php | 27 + app/Models/Jugador.php | 79 + app/Models/JugadorEquipo.php | 34 + app/Models/Noticia.php | 26 + app/Models/Notificacion.php | 40 + app/Models/Pase.php | 33 + app/Models/PromoQr.php | 46 + app/Models/Promocion.php | 35 + app/Models/PushSubscription.php | 24 + app/Models/QrCode.php | 48 + app/Models/Sponsor.php | 23 + app/Models/Torneo.php | 33 + app/Models/User.php | 49 + app/Observers/EventoObserver.php | 142 ++ app/Providers/AppServiceProvider.php | 57 + app/Services/FixtureService.php | 138 ++ app/Services/GeniusAgentService.php | 229 +++ app/Services/ImageOptimizer.php | 106 ++ app/Services/NotificacionService.php | 211 +++ app/Services/TournamentService.php | 149 ++ 76 files changed, 7737 insertions(+) create mode 100644 app/AI/Prompts/SystemPromptAdmin.php create mode 100644 app/AI/Prompts/SystemPromptPublic.php create mode 100644 app/AI/Tools/CargarPuntajeTool.php create mode 100644 app/AI/Tools/CrearPartidoTool.php create mode 100644 app/AI/Tools/EliminarNoticiaTool.php create mode 100644 app/AI/Tools/EliminarPartidoTool.php create mode 100644 app/AI/Tools/ListarEquiposTool.php create mode 100644 app/AI/Tools/ListarEventosTool.php create mode 100644 app/AI/Tools/ListarTorneosTool.php create mode 100644 app/AI/Tools/RedactarNoticiaTool.php create mode 100644 app/Console/Commands/CleanupOldEvents.php create mode 100644 app/Console/Commands/OptimizeImages.php create mode 100644 app/Console/Commands/PurgeAgentThreads.php create mode 100644 app/Console/Commands/RecordatorioPartidos.php create mode 100644 app/Console/Commands/ReporteSemanal.php create mode 100644 app/Http/Controllers/Admin/AdminUserController.php create mode 100644 app/Http/Controllers/Admin/CarouselItemController.php create mode 100644 app/Http/Controllers/Admin/CategoriaController.php create mode 100644 app/Http/Controllers/Admin/FixtureController.php create mode 100644 app/Http/Controllers/Admin/PaseController.php create mode 100644 app/Http/Controllers/AdminController.php create mode 100644 app/Http/Controllers/AdminUserController.php create mode 100644 app/Http/Controllers/AficionadoController.php create mode 100644 app/Http/Controllers/AuthController.php create mode 100644 app/Http/Controllers/ClubController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/DocumentacionController.php create mode 100644 app/Http/Controllers/EquipoController.php create mode 100644 app/Http/Controllers/EventoController.php create mode 100644 app/Http/Controllers/GeniusAgentController.php create mode 100644 app/Http/Controllers/HomeController.php create mode 100644 app/Http/Controllers/JugadorController.php create mode 100644 app/Http/Controllers/JugadorEquipoController.php create mode 100644 app/Http/Controllers/NoticiaController.php create mode 100644 app/Http/Controllers/NotificacionController.php create mode 100644 app/Http/Controllers/PanelController.php create mode 100644 app/Http/Controllers/PromoQrController.php create mode 100644 app/Http/Controllers/PromocionController.php create mode 100644 app/Http/Controllers/QrCodeController.php create mode 100644 app/Http/Controllers/QrDownloadController.php create mode 100644 app/Http/Controllers/SeguimientoController.php create mode 100644 app/Http/Controllers/TorneoController.php create mode 100644 app/Http/Middleware/SecurityHeaders.php create mode 100644 app/Mail/QrCodeMail.php create mode 100644 app/Mail/ResetPasswordMail.php create mode 100644 app/Mail/WelcomeMail.php create mode 100644 app/Models/AdminUser.php create mode 100644 app/Models/Aficionado.php create mode 100644 app/Models/AgentThread.php create mode 100644 app/Models/CarouselItem.php create mode 100644 app/Models/Categoria.php create mode 100644 app/Models/Club.php create mode 100644 app/Models/Configuracion.php create mode 100644 app/Models/Equipo.php create mode 100644 app/Models/EquipoSeguimiento.php create mode 100644 app/Models/Evento.php create mode 100644 app/Models/EventoJugador.php create mode 100644 app/Models/Jugador.php create mode 100644 app/Models/JugadorEquipo.php create mode 100644 app/Models/Noticia.php create mode 100644 app/Models/Notificacion.php create mode 100644 app/Models/Pase.php create mode 100644 app/Models/PromoQr.php create mode 100644 app/Models/Promocion.php create mode 100644 app/Models/PushSubscription.php create mode 100644 app/Models/QrCode.php create mode 100644 app/Models/Sponsor.php create mode 100644 app/Models/Torneo.php create mode 100644 app/Models/User.php create mode 100644 app/Observers/EventoObserver.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Services/FixtureService.php create mode 100644 app/Services/GeniusAgentService.php create mode 100644 app/Services/ImageOptimizer.php create mode 100644 app/Services/NotificacionService.php create mode 100644 app/Services/TournamentService.php diff --git a/app/AI/Prompts/SystemPromptAdmin.php b/app/AI/Prompts/SystemPromptAdmin.php new file mode 100644 index 0000000..199f942 --- /dev/null +++ b/app/AI/Prompts/SystemPromptAdmin.php @@ -0,0 +1,72 @@ +'); + $faqPos = strpos($content, '## ❓ Preguntas Frecuentes'); + + if ($cap4Pos !== false && $faqPos !== false && $faqPos > $cap4Pos) { + $content = substr($content, 0, $cap4Pos) . substr($content, $faqPos); + } + + return $content; + } +} diff --git a/app/AI/Tools/CargarPuntajeTool.php b/app/AI/Tools/CargarPuntajeTool.php new file mode 100644 index 0000000..cb54d13 --- /dev/null +++ b/app/AI/Tools/CargarPuntajeTool.php @@ -0,0 +1,30 @@ + "No se encontró el partido con ID: {$id_evento}"]); + } + + $evento->update([ + 'marcador_local' => $marcador_local, + 'marcador_visitante' => $marcador_visitante, + ]); + + return json_encode([ + 'success' => true, + 'mensaje' => "Puntaje actualizado: {$marcador_local} - {$marcador_visitante}", + ]); + } +} diff --git a/app/AI/Tools/CrearPartidoTool.php b/app/AI/Tools/CrearPartidoTool.php new file mode 100644 index 0000000..1b94e5b --- /dev/null +++ b/app/AI/Tools/CrearPartidoTool.php @@ -0,0 +1,43 @@ + (string) Str::uuid(), + 'id_equipo_local' => $id_equipo_local, + 'id_equipo_visitante' => $id_equipo_visitante, + 'fecha_evento' => $fecha_evento, + 'hora_inicio' => str_contains($hora_inicio, ':') ? $hora_inicio . (strlen($hora_inicio) === 5 ? ':00' : '') : $hora_inicio, + 'hora_fin' => str_contains($hora_fin, ':') ? $hora_fin . (strlen($hora_fin) === 5 ? ':00' : '') : $hora_fin, + 'sede' => $sede, + 'id_torneo' => $id_torneo, + 'precio' => $precio ?? 0, + 'fase' => Evento::FASE_REGULAR, + ]); + + return json_encode([ + 'success' => true, + 'id_evento' => $evento->id_evento, + 'mensaje' => "Partido creado correctamente. ID: {$evento->id_evento}", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo crear el partido: ' . $e->getMessage()]); + } + } +} diff --git a/app/AI/Tools/EliminarNoticiaTool.php b/app/AI/Tools/EliminarNoticiaTool.php new file mode 100644 index 0000000..0497111 --- /dev/null +++ b/app/AI/Tools/EliminarNoticiaTool.php @@ -0,0 +1,29 @@ + "No se encontró la noticia con ID: {$id_noticia}"]); + } + + $titulo = $noticia->titulo; + $noticia->delete(); + + return json_encode([ + 'success' => true, + 'mensaje' => "Noticia \"{$titulo}\" (ID {$id_noticia}) eliminada correctamente.", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo eliminar la noticia: ' . $e->getMessage()]); + } + } +} diff --git a/app/AI/Tools/EliminarPartidoTool.php b/app/AI/Tools/EliminarPartidoTool.php new file mode 100644 index 0000000..0308804 --- /dev/null +++ b/app/AI/Tools/EliminarPartidoTool.php @@ -0,0 +1,28 @@ + "No se encontró el partido con ID: {$id_evento}"]); + } + + $evento->delete(); + + return json_encode([ + 'success' => true, + 'mensaje' => "Partido {$id_evento} eliminado correctamente (soft delete).", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo eliminar el partido: ' . $e->getMessage()]); + } + } +} diff --git a/app/AI/Tools/ListarEquiposTool.php b/app/AI/Tools/ListarEquiposTool.php new file mode 100644 index 0000000..3ab9def --- /dev/null +++ b/app/AI/Tools/ListarEquiposTool.php @@ -0,0 +1,33 @@ +join('torneo_equipo', 'equipos.id_equipo', '=', 'torneo_equipo.id_equipo') + ->where('torneo_equipo.id_torneo', $id_torneo); + + if ($grupo !== null) { + $query->where('torneo_equipo.grupo', $grupo); + } + + $query->select('equipos.id_equipo', 'equipos.categoria', 'equipos.division', 'equipos.id_club'); + } + + $equipos = $query->get()->map(fn($e) => [ + 'id_equipo' => $e->id_equipo, + 'categoria' => $e->categoria, + 'division' => $e->division, + 'club' => $e->club?->nombre, + ]); + + return json_encode($equipos); + } +} diff --git a/app/AI/Tools/ListarEventosTool.php b/app/AI/Tools/ListarEventosTool.php new file mode 100644 index 0000000..fb6aa72 --- /dev/null +++ b/app/AI/Tools/ListarEventosTool.php @@ -0,0 +1,40 @@ +whereNull('deleted_at'); + + if ($fecha_desde) { + $query->whereDate('fecha_evento', '>=', $fecha_desde); + } + if ($fecha_hasta) { + $query->whereDate('fecha_evento', '<=', $fecha_hasta); + } + if ($id_torneo) { + $query->where('id_torneo', $id_torneo); + } + + $eventos = $query->orderBy('fecha_evento')->get()->map(fn($e) => [ + 'id_evento' => $e->id_evento, + 'fecha' => $e->fecha_evento?->format('Y-m-d'), + 'hora' => $e->hora_inicio?->format('H:i'), + 'local' => $e->equipoLocal?->club?->nombre, + 'visitante' => $e->equipoVisitante?->club?->nombre, + 'marcador_local' => $e->marcador_local, + 'marcador_visitante' => $e->marcador_visitante, + 'sede' => $e->sede, + ]); + + return json_encode($eventos); + } +} diff --git a/app/AI/Tools/ListarTorneosTool.php b/app/AI/Tools/ListarTorneosTool.php new file mode 100644 index 0000000..a1b8f0e --- /dev/null +++ b/app/AI/Tools/ListarTorneosTool.php @@ -0,0 +1,20 @@ +get()->map(fn ($t) => [ + 'id_torneo' => $t->id, + 'nombre' => $t->nombre, + 'fecha_inicio' => $t->fecha_inicio?->format('Y-m-d'), + 'fecha_fin' => $t->fecha_fin?->format('Y-m-d'), + ]); + + return json_encode($torneos); + } +} diff --git a/app/AI/Tools/RedactarNoticiaTool.php b/app/AI/Tools/RedactarNoticiaTool.php new file mode 100644 index 0000000..271ad01 --- /dev/null +++ b/app/AI/Tools/RedactarNoticiaTool.php @@ -0,0 +1,33 @@ + $titulo, + 'contenido' => $contenido, + 'fecha' => now(), + 'id_torneo' => $id_torneo, + 'categoria' => $categoria, + ]); + + return json_encode([ + 'success' => true, + 'id_noticia' => $noticia->id, + 'mensaje' => "Noticia \"{$titulo}\" creada correctamente.", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo crear la noticia: ' . $e->getMessage()]); + } + } +} diff --git a/app/Console/Commands/CleanupOldEvents.php b/app/Console/Commands/CleanupOldEvents.php new file mode 100644 index 0000000..6c0d370 --- /dev/null +++ b/app/Console/Commands/CleanupOldEvents.php @@ -0,0 +1,51 @@ +subDays((int)$dias); + + $eventosAEliminar = \App\Models\Evento::withTrashed() + ->where('fecha_evento', '<', $fechaLimite->toDateString()) + ->get(); + $total = $eventosAEliminar->count(); + + if ($total === 0) { + $this->info("No hay eventos antiguos para eliminar."); + return; + } + + foreach ($eventosAEliminar as $evento) { + /** @var \App\Models\Evento $evento */ + // Eliminar QRs asociados + $evento->qrCodes()->delete(); + // Ya no eliminamos el evento para mantener registro de puntos y goleadores + // $evento->delete(); + } + + $this->info("Se han limpiado los QRs de $total eventos antiguos (Antigüedad > $dias días). Los eventos permanecen en el sistema."); + } +} diff --git a/app/Console/Commands/OptimizeImages.php b/app/Console/Commands/OptimizeImages.php new file mode 100644 index 0000000..d2a430c --- /dev/null +++ b/app/Console/Commands/OptimizeImages.php @@ -0,0 +1,196 @@ + ['maxWidth' => 1600, 'quality' => 82], + 'noticias' => ['maxWidth' => 1200, 'quality' => 82], + 'promos' => ['maxWidth' => 1200, 'quality' => 82], + 'clubes' => ['maxWidth' => 512, 'quality' => 85], + 'sponsors' => ['maxWidth' => 600, 'quality' => 85], + 'qr' => ['maxWidth' => 800, 'quality' => 85], + ]; + + public function handle(): int + { + $apply = (bool) $this->option('apply'); + $only = $this->option('folder'); + $noBackup = (bool) $this->option('no-backup'); + + if (!extension_loaded('gd')) { + $this->error('La extension GD de PHP no esta instalada.'); + return self::FAILURE; + } + + // Usa la config real del disk 'public' (respeta override de Hostinger) + $base = realpath(config('filesystems.disks.public.root')) ?: config('filesystems.disks.public.root'); + if (!is_dir($base)) { + $this->error("No existe: $base"); + return self::FAILURE; + } + $this->line("Base: $base"); + + $backupBase = $base . '/_backup_optimize'; + if ($apply && !$noBackup && !is_dir($backupBase)) { + mkdir($backupBase, 0755, true); + } + + $this->line(''); + $this->line($apply + ? 'MODO APLICACION — los archivos seran reemplazados.' + : 'MODO DRY-RUN — no se modifica nada. Pasa --apply para aplicar.'); + $this->line(''); + + $totalOrig = 0; $totalNew = 0; $totalProcessed = 0; $totalSkipped = 0; + + foreach ($this->config as $folder => $cfg) { + if ($only && $only !== $folder) continue; + + $path = $base . '/' . $folder; + if (!is_dir($path)) { + $this->warn(" - $folder/ (no existe, skip)"); + continue; + } + + $this->info("Carpeta: $folder/ (max {$cfg['maxWidth']}px, q={$cfg['quality']})"); + $files = glob($path . '/*.{jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP}', GLOB_BRACE); + + $folderOrig = 0; $folderNew = 0; $folderProcessed = 0; $folderSkipped = 0; + + foreach ($files as $file) { + $origSize = filesize($file); + $totalOrig += $origSize; $folderOrig += $origSize; + + $info = @getimagesize($file); + if (!$info) { $folderSkipped++; $totalSkipped++; continue; } + + [$w, $h] = $info; + $needsResize = $w > $cfg['maxWidth']; + $needsRecomp = $origSize > 100 * 1024; // skip si ya pesa <100KB + + if (!$needsResize && !$needsRecomp) { + $folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize; + continue; + } + + $result = $this->processImage($file, $cfg['maxWidth'], $cfg['quality']); + if ($result === null) { + $folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize; + continue; + } + + [$newBytes, $newW, $newH] = $result; + + if ($newBytes >= $origSize) { + $folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize; + $this->line(sprintf(" - %-40s %dKB no mejora, skip", basename($file), $origSize / 1024)); + continue; + } + + $folderProcessed++; $totalProcessed++; + $totalNew += $newBytes; $folderNew += $newBytes; + + $reduction = round((1 - $newBytes / $origSize) * 100, 1); + $this->line(sprintf( + " %s %-40s %dKB -> %dKB (-%s%%) %dx%d -> %dx%d", + $apply ? 'OK' : '--', + basename($file), $origSize / 1024, $newBytes / 1024, $reduction, + $w, $h, $newW, $newH + )); + + if ($apply) { + if (!$noBackup) { + $backupDir = $backupBase . '/' . $folder; + if (!is_dir($backupDir)) mkdir($backupDir, 0755, true); + copy($file, $backupDir . '/' . basename($file)); + } + file_put_contents($file, $result[3]); + } + } + + $this->line(sprintf( + " -> %s files procesados, %s skip, %s -> %s (-%s%%)", + $folderProcessed, $folderSkipped, + $this->fmt($folderOrig), $this->fmt($folderNew), + $folderOrig > 0 ? round((1 - $folderNew / max($folderOrig, 1)) * 100, 1) : 0 + )); + $this->line(''); + } + + $this->line(str_repeat('=', 60)); + $this->info(sprintf( + 'TOTAL: %d procesados, %d skip. %s -> %s (ahorro: %s, -%s%%)', + $totalProcessed, $totalSkipped, + $this->fmt($totalOrig), $this->fmt($totalNew), + $this->fmt($totalOrig - $totalNew), + $totalOrig > 0 ? round((1 - $totalNew / max($totalOrig, 1)) * 100, 1) : 0 + )); + + if (!$apply && $totalProcessed > 0) { + $this->line(''); + $this->warn('Para aplicar realmente: php artisan optimize:images --apply'); + } + + return self::SUCCESS; + } + + private function processImage(string $file, int $maxWidth, int $quality): ?array + { + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + + $img = match ($ext) { + 'jpg', 'jpeg' => @imagecreatefromjpeg($file), + 'png' => @imagecreatefrompng($file), + 'webp' => @imagecreatefromwebp($file), + default => null, + }; + if (!$img) return null; + + $w = imagesx($img); $h = imagesy($img); + $newW = $w; $newH = $h; + + if ($w > $maxWidth) { + $newW = $maxWidth; + $newH = (int) round($h * ($maxWidth / $w)); + $resized = imagecreatetruecolor($newW, $newH); + + if (in_array($ext, ['png', 'webp'])) { + imagealphablending($resized, false); + imagesavealpha($resized, true); + $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127); + imagefilledrectangle($resized, 0, 0, $newW, $newH, $transparent); + } + + imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + $img = $resized; + } + + ob_start(); + match ($ext) { + 'jpg', 'jpeg' => imagejpeg($img, null, $quality), + 'png' => imagepng($img, null, 9), + 'webp' => imagewebp($img, null, $quality), + }; + $bytes = ob_get_clean(); + + return [strlen($bytes), $newW, $newH, $bytes]; + } + + private function fmt(int $bytes): string + { + if ($bytes < 1024) return $bytes . 'B'; + if ($bytes < 1024 * 1024) return round($bytes / 1024, 1) . 'KB'; + return round($bytes / 1024 / 1024, 2) . 'MB'; + } +} diff --git a/app/Console/Commands/PurgeAgentThreads.php b/app/Console/Commands/PurgeAgentThreads.php new file mode 100644 index 0000000..c95e329 --- /dev/null +++ b/app/Console/Commands/PurgeAgentThreads.php @@ -0,0 +1,21 @@ +delete(); + + $this->info("Se eliminaron {$deleted} hilo(s) expirado(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecordatorioPartidos.php b/app/Console/Commands/RecordatorioPartidos.php new file mode 100644 index 0000000..2f56a12 --- /dev/null +++ b/app/Console/Commands/RecordatorioPartidos.php @@ -0,0 +1,86 @@ +addHours(24); + $hasta = Carbon::now()->addHours(50); // ventana de ~26hs para no perder ninguno + + $eventos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->whereNotNull('id_equipo_local') + ->whereNotNull('id_equipo_visitante') + ->whereBetween('fecha_evento', [$desde->toDateString(), $hasta->toDateString()]) + ->get(); + + if ($eventos->isEmpty()) { + $this->info('No hay partidos en las próximas 48hs.'); + return self::SUCCESS; + } + + $totalNotif = 0; + + foreach ($eventos as $evento) { + $nombreLocal = $evento->equipoLocal->club->nombre ?? '?'; + $nombreVisitante = $evento->equipoVisitante->club->nombre ?? '?'; + $fechaStr = $evento->fecha_evento->format('d/m/Y'); + $horaStr = $evento->hora_inicio ? $evento->hora_inicio->format('H:i') : ''; + $sedeStr = $evento->sede ? " | {$evento->sede}" : ''; + + $titulo = "⏰ Partido mañana: {$nombreLocal} vs {$nombreVisitante}"; + $mensaje = "Recordatorio: el partido es el {$fechaStr}" . ($horaStr ? " a las {$horaStr}" : '') . $sedeStr . '.'; + $url = '/eventos/' . $evento->id_evento; + + // Recolectar destinatarios + $idEquipos = array_filter([$evento->id_equipo_local, $evento->id_equipo_visitante]); + $destinatarios = []; + $yaAgregados = []; + + $seguimientos = EquipoSeguimiento::whereIn('id_equipo', $idEquipos)->get(); + foreach ($seguimientos as $s) { + $key = $s->tipo_usuario . ':' . $s->id_usuario; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => $s->tipo_usuario, 'id' => $s->id_usuario]; + $yaAgregados[$key] = true; + } + } + + $jugadores = \DB::table('jugador_equipo') + ->whereIn('id_equipo', $idEquipos) + ->pluck('id_jugador'); + + foreach ($jugadores as $idJ) { + $key = 'jugador:' . $idJ; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => 'jugador', 'id' => $idJ]; + $yaAgregados[$key] = true; + } + } + + if (empty($destinatarios)) continue; + + if ($this->option('test')) { + $this->line(" [TEST] Evento {$evento->id_evento}: {$titulo} → " . count($destinatarios) . " destinatarios"); + } else { + $notifService->enviarMasivo($destinatarios, 'partido', $titulo, $mensaje, $url); + } + + $totalNotif += count($destinatarios); + } + + $this->info("✅ Recordatorios enviados: {$eventos->count()} partidos, {$totalNotif} notificaciones."); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ReporteSemanal.php b/app/Console/Commands/ReporteSemanal.php new file mode 100644 index 0000000..9eb1abb --- /dev/null +++ b/app/Console/Commands/ReporteSemanal.php @@ -0,0 +1,106 @@ +copy()->subDays(7)->startOfDay(); + $semanaAnteriorHasta = $ahora->copy()->subDay()->endOfDay(); + $proximaSemanaHasta = $ahora->copy()->addDays(7)->endOfDay(); + + // ── Partidos jugados la semana anterior ── + $jugados = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->whereBetween('fecha_evento', [$semanaAnteriorDesde->toDateString(), $semanaAnteriorHasta->toDateString()]) + ->whereNotNull('marcador_local') + ->get(); + + // ── Próximos partidos ── + $proximos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->where('fecha_evento', '>=', $ahora->toDateString()) + ->where('fecha_evento', '<=', $proximaSemanaHasta->toDateString()) + ->orderBy('fecha_evento') + ->orderBy('hora_inicio') + ->get(); + + // ── Top goleadores ── + $topGoleadores = DB::table('evento_jugador') + ->join('jugadores', 'evento_jugador.id_jugador', '=', 'jugadores.id_jugador') + ->selectRaw('jugadores.nombre, jugadores.apellido, SUM(evento_jugador.puntos) as total_puntos, COUNT(evento_jugador.id_evento) as partidos') + ->groupBy('jugadores.id_jugador', 'jugadores.nombre', 'jugadores.apellido') + ->orderByDesc('total_puntos') + ->limit(10) + ->get(); + + // ── QRs de la semana ── + $qrsSemana = DB::table('qr_codes') + ->where('creado', '>=', $semanaAnteriorDesde) + ->count(); + + $qrsValidados = DB::table('qr_codes') + ->where('creado', '>=', $semanaAnteriorDesde) + ->where('escaneos_restantes', 0) + ->count(); + + $data = compact('jugados', 'proximos', 'topGoleadores', 'qrsSemana', 'qrsValidados', 'semanaAnteriorDesde', 'semanaAnteriorHasta'); + + if ($this->option('dry-run')) { + $this->info('=== REPORTE SEMANAL (DRY RUN) ==='); + $this->line("Partidos jugados: {$jugados->count()}"); + $this->line("Próximos partidos: {$proximos->count()}"); + $this->line("Top goleador: " . ($topGoleadores->first() ? $topGoleadores->first()->apellido . ' (' . $topGoleadores->first()->total_puntos . ' pts)' : 'N/A')); + $this->line("QRs generados: {$qrsSemana} | Validados: {$qrsValidados}"); + return self::SUCCESS; + } + + // 1. Priorizar email configurado en sistema + $emails = []; + $configEmail = \App\Models\Configuracion::get('email_reportes'); + if ($configEmail) { + $emails[] = $configEmail; + } + + // 2. Si no hay, buscar superadmins (rol=1) + if (empty($emails)) { + $emails = DB::table('admin_users')->where('rol', 1)->whereNotNull('email')->pluck('email')->toArray(); + } + + // 3. Fallback: email del .env + if (empty($emails)) { + $fallback = config('mail.from.address'); + if ($fallback) $emails = [$fallback]; + } + + if (empty($emails)) { + $this->error('No hay destinatarios configurados para enviar el reporte.'); + return self::FAILURE; + } + + try { + Mail::send('emails.reporte_semanal', $data, function ($mail) use ($emails, $ahora) { + $mail->to($emails) + ->subject("📊 Reporte Semanal ONAPB — Semana del " . $ahora->startOfWeek()->format('d/m')); + }); + $this->info("✅ Reporte enviado a: " . implode(', ', $emails)); + } catch (\Exception $e) { + Log::error('Error enviando reporte semanal: ' . $e->getMessage()); + $this->error('Error: ' . $e->getMessage()); + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php new file mode 100644 index 0000000..3042a13 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -0,0 +1,115 @@ +checkSuperAdmin($request); + $usuarios = AdminUser::with('club')->orderBy('id', 'desc')->paginate(20); + return view('admin.usuarios.index', compact('usuarios')); + } + + public function create(Request $request) + { + $this->checkSuperAdmin($request); + $usuario = null; + $clubes = Club::orderBy('nombre')->get(); + return view('admin.usuarios.form', compact('usuario', 'clubes')); + } + + public function store(Request $request) + { + $this->checkSuperAdmin($request); + + $data = $request->validate([ + 'username' => 'required|string|max:50|unique:admin_users', + 'password' => 'required|string|min:6', + 'role' => 'required|integer|in:1,2', + 'id_club' => 'nullable|integer|exists:clubes,id_club' + ]); + + if ($data['role'] == 2 && empty($data['id_club'])) { + return back()->withErrors(['id_club' => 'Si el rol es Admin de Club, se requiere un club asociado.'])->withInput(); + } + + if ($data['role'] == 1) { + $data['id_club'] = null; // Superadmins no pertenecen a un club específico en este contexto + } + + $data['password'] = Hash::make($data['password']); + + AdminUser::create($data); + + return redirect()->route('admin.usuarios.index')->with('admin_msg', 'Administrador creado exitosamente.'); + } + + public function edit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $usuario = AdminUser::findOrFail($id); + $clubes = Club::orderBy('nombre')->get(); + return view('admin.usuarios.form', compact('usuario', 'clubes')); + } + + public function update(Request $request, $id) + { + $this->checkSuperAdmin($request); + $usuario = AdminUser::findOrFail($id); + + $data = $request->validate([ + 'username' => ['required', 'string', 'max:50', Rule::unique('admin_users')->ignore($usuario->id)], + 'password' => 'nullable|string|min:6', + 'role' => 'required|integer|in:1,2', + 'id_club' => 'nullable|integer|exists:clubes,id_club' + ]); + + if ($data['role'] == 2 && empty($data['id_club'])) { + return back()->withErrors(['id_club' => 'Si el rol es Admin de Club, se requiere un club asociado.'])->withInput(); + } + + if ($data['role'] == 1) { + $data['id_club'] = null; + } + + if (!empty($data['password'])) { + $data['password'] = Hash::make($data['password']); + } else { + unset($data['password']); + } + + $usuario->update($data); + + return redirect()->route('admin.usuarios.index')->with('admin_msg', 'Administrador actualizado exitosamente.'); + } + + public function destroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + + $usuario = AdminUser::findOrFail($id); + + if ($usuario->id == session('admin_id')) { + return back()->with('admin_error', 'No puedes eliminar tu propio usuario.'); + } + + $usuario->delete(); + + return redirect()->route('admin.usuarios.index')->with('admin_msg', 'Administrador eliminado.'); + } +} diff --git a/app/Http/Controllers/Admin/CarouselItemController.php b/app/Http/Controllers/Admin/CarouselItemController.php new file mode 100644 index 0000000..afecb19 --- /dev/null +++ b/app/Http/Controllers/Admin/CarouselItemController.php @@ -0,0 +1,113 @@ +checkGeneralAdmin($request); + $items = CarouselItem::orderBy('orden', 'asc')->latest()->get(); + return view('admin.carousel.index', compact('items')); + } + + public function create(Request $request) + { + $this->checkGeneralAdmin($request); + return view('admin.carousel.create'); + } + + public function store(Request $request) + { + $this->checkGeneralAdmin($request); + $request->validate([ + 'titulo' => 'nullable|string|max:255', + 'subtitulo' => 'nullable|string|max:255', + 'boton_texto' => 'nullable|string|max:255', + 'boton_enlace' => 'nullable|string|max:255', + 'imagen' => 'required|image|mimes:jpeg,png,jpg,webp|max:5120', + 'orden' => 'nullable|integer', + 'activo' => 'nullable|boolean', + ]); + + $data = $request->except('imagen'); + $data['activo'] = $request->has('activo'); + + if ($request->hasFile('imagen')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'carousel'); + $data['imagen'] = 'storage/' . $path; + } + + CarouselItem::create($data); + + return redirect()->route('admin.carousel.index')->with('admin_msg', 'Slide creado exitosamente.'); + } + + public function edit(Request $request, CarouselItem $carouselItem) + { + $this->checkGeneralAdmin($request); + return view('admin.carousel.edit', compact('carouselItem')); + } + + public function update(Request $request, CarouselItem $carouselItem) + { + $this->checkGeneralAdmin($request); + $request->validate([ + 'titulo' => 'nullable|string|max:255', + 'subtitulo' => 'nullable|string|max:255', + 'boton_texto' => 'nullable|string|max:255', + 'boton_enlace' => 'nullable|string|max:255', + 'imagen' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:5120', + 'orden' => 'nullable|integer', + 'activo' => 'nullable|boolean', + ]); + + $data = $request->except('imagen'); + $data['activo'] = $request->has('activo'); + + if ($request->hasFile('imagen')) { + // Eliminar imagen anterior si existe + if ($carouselItem->imagen) { + $oldPath = public_path($carouselItem->imagen); + if (File::exists($oldPath)) { + File::delete($oldPath); + } + } + + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'carousel'); + $data['imagen'] = 'storage/' . $path; + } + + $carouselItem->update($data); + + return redirect()->route('admin.carousel.index')->with('admin_msg', 'Slide actualizado exitosamente.'); + } + + public function destroy(Request $request, CarouselItem $carouselItem) + { + $this->checkGeneralAdmin($request); + if ($carouselItem->imagen) { + $imagePath = public_path($carouselItem->imagen); + if (File::exists($imagePath)) { + File::delete($imagePath); + } + } + + $carouselItem->delete(); + + return redirect()->route('admin.carousel.index')->with('admin_msg', 'Slide eliminado exitosamente.'); + } +} diff --git a/app/Http/Controllers/Admin/CategoriaController.php b/app/Http/Controllers/Admin/CategoriaController.php new file mode 100644 index 0000000..66a8ef3 --- /dev/null +++ b/app/Http/Controllers/Admin/CategoriaController.php @@ -0,0 +1,74 @@ +checkSuperAdmin($request); + $categorias = Categoria::latest()->get(); + return view('admin.categorias.index', compact('categorias')); + } + + public function create(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.categorias.form', ['categoria' => null]); + } + + public function store(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:50', + 'edad_min' => 'required|integer', + 'edad_max' => 'required|integer|gte:edad_min', + 'genero' => 'nullable|string|max:20', + ]); + $data['es_libre'] = $request->has('es_libre'); + Categoria::create($data); + return redirect()->route('admin.categorias.index')->with('admin_msg', 'Categoría creada.'); + } + + public function edit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $categoria = Categoria::findOrFail($id); + return view('admin.categorias.form', compact('categoria')); + } + + public function update(Request $request, $id) + { + $this->checkSuperAdmin($request); + $categoria = Categoria::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'required|string|max:50', + 'edad_min' => 'required|integer', + 'edad_max' => 'required|integer|gte:edad_min', + 'genero' => 'nullable|string|max:20', + ]); + $data['es_libre'] = $request->has('es_libre'); + $categoria->update($data); + return redirect()->route('admin.categorias.index')->with('admin_msg', 'Categoría actualizada.'); + } + + public function destroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $categoria = Categoria::findOrFail($id); + $categoria->delete(); + return redirect()->route('admin.categorias.index')->with('admin_msg', 'Categoría eliminada.'); + } +} diff --git a/app/Http/Controllers/Admin/FixtureController.php b/app/Http/Controllers/Admin/FixtureController.php new file mode 100644 index 0000000..7101fa7 --- /dev/null +++ b/app/Http/Controllers/Admin/FixtureController.php @@ -0,0 +1,380 @@ +fixtureService = $fixtureService; + } + + private function checkSuperAdmin() + { + if (!session('admin_logged_in') || session('admin_role') != 1) { + abort(403, 'Solo Súper Administradores pueden generar fixtures.'); + } + } + + /** + * POST /admin/torneos/{id}/generar-fixture — Preview del fixture + */ + public function preview(Request $request, int $id) + { + $this->checkSuperAdmin(); + + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + $request->validate([ + 'fecha_inicio' => 'required|date|after_or_equal:today', + 'dias_entre_jornadas' => 'required|integer|min:1|max:60', + 'sede_default' => 'nullable|string|max:200', + 'doble_rueda' => 'nullable|boolean', + ]); + + try { + $partidos = $this->fixtureService->generarRoundRobin( + torneo: $torneo, + fechaInicio: $request->input('fecha_inicio'), + diasEntreJornadas: (int) $request->input('dias_entre_jornadas', 7), + sedeDefault: $request->input('sede_default', ''), + dobleRueda: (bool) $request->input('doble_rueda', false), + ); + + // Enriquecer con nombres para la vista + $partidosEnriquecidos = collect($partidos)->map(function ($p) { + $local = \App\Models\Equipo::with('club')->find($p['id_equipo_local']); + $visitante = \App\Models\Equipo::with('club')->find($p['id_equipo_visitante']); + return array_merge($p, [ + 'nombre_local' => ($local->club->nombre ?? '?') . ' (' . ($local->categoria ?? '') . ')', + 'nombre_visitante' => ($visitante->club->nombre ?? '?') . ' (' . ($visitante->categoria ?? '') . ')', + ]); + })->toArray(); + + } catch (\InvalidArgumentException $e) { + return back()->with('admin_error', $e->getMessage()); + } + + $fixtureParams = [ + 'fecha_inicio' => $request->input('fecha_inicio'), + 'dias_entre_jornadas' => $request->input('dias_entre_jornadas', 7), + 'sede_default' => $request->input('sede_default', ''), + 'doble_rueda' => (bool) $request->input('doble_rueda', false), + ]; + + return view('admin.torneos.fixture_preview', compact('torneo', 'partidosEnriquecidos', 'fixtureParams')); + + } + + /** + * POST /admin/torneos/{id}/confirmar-fixture — Persiste el fixture + */ + public function confirmar(Request $request, int $id) + { + $this->checkSuperAdmin(); + + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + $request->validate([ + 'fecha_inicio' => 'required|date', + 'dias_entre_jornadas' => 'required|integer|min:1', + 'sede_default' => 'nullable|string|max:200', + 'doble_rueda' => 'nullable|boolean', + ]); + + try { + $partidos = $this->fixtureService->generarRoundRobin( + torneo: $torneo, + fechaInicio: $request->input('fecha_inicio'), + diasEntreJornadas: (int) $request->input('dias_entre_jornadas', 7), + sedeDefault: $request->input('sede_default', ''), + dobleRueda: (bool) $request->input('doble_rueda', false), + ); + + $creados = $this->fixtureService->persistirFixture($partidos, $torneo); + + } catch (\InvalidArgumentException $e) { + return back()->with('admin_error', $e->getMessage()); + } + + return redirect()->route('admin.torneos.edit', $id) + ->with('admin_msg', "✅ Fixture generado correctamente: {$creados} partidos creados."); + } + + public function importForm(int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::findOrFail($id); + return view('admin.torneos.importar', compact('torneo')); + } + + public function importStore(Request $request, int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::with(['equipos.club'])->findOrFail($id); + + $request->validate([ + 'texto_importar' => 'required|string', + ]); + + $lineas = explode("\n", $request->input('texto_importar')); + $creados = 0; + $errores = []; + + foreach ($lineas as $index => $linea) { + $linea = trim($linea); + if (empty($linea)) continue; + + try { + // Formato esperado: Fecha, ClubL, CatL, ClubV, CatV, MarcadorL, MarcadorV, Sede, Grupo + $partes = str_getcsv($linea); + + if (count($partes) < 7) { + throw new \Exception("Formato insuficiente. Se esperan al menos 7 columnas."); + } + + $fechaRaw = trim($partes[0]); + $fecha = \Carbon\Carbon::parse(str_replace('/', '-', $fechaRaw))->format('Y-m-d'); + + $nombreClubL = trim($partes[1]); + $catL = trim($partes[2]); + $nombreClubV = trim($partes[3]); + $catV = trim($partes[4]); + $marcadorL = trim($partes[5]); + $marcadorV = trim($partes[6]); + $sede = $partes[7] ?? null; + $grupo = $partes[8] ?? null; + + // Buscar equipos con validación de grupo (prioridad) y categoría + $equipoL = $this->buscarEquipo($nombreClubL, $catL, $torneo, $grupo); + $equipoV = $this->buscarEquipo($nombreClubV, $catV, $torneo, $grupo); + + if (!$equipoL || !$equipoV) { + $missing = !$equipoL ? "'{$nombreClubL} ({$catL})'" : "'{$nombreClubV} ({$catV})'"; + throw new \Exception("No se encontró el equipo {$missing} en el grupo '" . ($grupo ?? 'N/A') . "' ni por categoría."); + } + + \App\Models\Evento::create([ + 'id_evento' => uniqid('ev_imp_'), + 'id_torneo' => $id, + 'fase' => \App\Models\Evento::FASE_REGULAR, + 'fecha_evento' => $fecha, + 'id_equipo_local' => $equipoL->id_equipo, + 'id_equipo_visitante' => $equipoV->id_equipo, + 'marcador_local' => $marcadorL !== '' ? (int)$marcadorL : null, + 'marcador_visitante' => $marcadorV !== '' ? (int)$marcadorV : null, + 'nombre_evento' => $equipoL->club->nombre . ' vs ' . $equipoV->club->nombre, + 'hora_inicio' => '19:00:00', + 'hora_fin' => '21:00:00', + 'sede' => $sede, + 'precio' => 0, + ]); + + $creados++; + } catch (\Exception $e) { + $errores[] = "Línea " . ($index + 1) . ": " . $e->getMessage(); + continue; + } + } + + $msg = "Se importaron {$creados} partidos exitosamente."; + if (count($errores) > 0) { + return back()->with('admin_msg', $msg)->with('admin_error', "Se detectaron errores en " . count($errores) . " líneas:
" . implode("
", $errores)); + } + + return redirect()->route('admin.torneos.edit', $id)->with('admin_msg', $msg); + } + + private function buscarEquipo(string $clubName, string $category, Torneo $torneo, ?string $grupo = null) + { + $clubNameNorm = $this->normalizeString($clubName); + $categoryNorm = $this->normalizeString($category); + $grupoNorm = $grupo ? $this->normalizeString($grupo) : null; + + // PASO 1: Prioridad absoluta al GRUPO (vía pivot) + if ($grupoNorm) { + $match = $torneo->equipos->first(function($e) use ($clubNameNorm, $grupoNorm) { + $eClubName = $this->normalizeString($e->club->nombre ?? ''); + $eGrupo = $this->normalizeString($e->pivot->grupo ?? ''); + + return ($eGrupo === $grupoNorm) && + (strpos($eClubName, $clubNameNorm) !== false || strpos($clubNameNorm, $eClubName) !== false); + }); + if ($match) return $match; + } + + // PASO 2: Fallback a búsqueda por CATEGORÍA si el grupo no coincide o no se especificó + return $torneo->equipos->first(function($e) use ($clubNameNorm, $categoryNorm) { + $eClubName = $this->normalizeString($e->club->nombre ?? ''); + $eCategory = $this->normalizeString($e->categoria ?? ''); + + $matchClub = (strpos($eClubName, $clubNameNorm) !== false || strpos($clubNameNorm, $eClubName) !== false); + $matchCat = (strpos($eCategory, $categoryNorm) !== false || strpos($categoryNorm, $eCategory) !== false); + + return $matchClub && $matchCat; + }); + } + + private function normalizeString(?string $str): string + { + if (!$str) return ''; + $str = mb_strtolower(trim($str), 'UTF-8'); + $str = str_replace( + ['á', 'é', 'í', 'ó', 'ú', 'ü', 'ñ'], + ['a', 'e', 'i', 'o', 'u', 'u', 'n'], + $str + ); + // Eliminar caracteres no alfanuméricos para un matching más flexible + return preg_replace('/[^a-z0-9]/', '', $str); + } + + public function generarPlayoffs(Request $request, int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::findOrFail($id); + $grupo = $request->input('grupo'); + $formato = (int) $request->input('formato', 1); // 1, 3, 5 + + if (!$grupo) { + return back()->with('admin_error', 'Debes seleccionar un grupo para generar los playoffs.'); + } + + $ts = new \App\Services\TournamentService(); + $standings = $ts->getStandings($id, true); + + if (!isset($standings[$grupo])) { + return back()->with('admin_error', "No hay equipos en el grupo {$grupo}."); + } + + $top8 = array_slice($standings[$grupo], 0, 8); + + if (count($top8) < 2) { + return back()->with('admin_error', "Se necesitan al menos 2 equipos para generar playoffs."); + } + + // Determinar emparejamientos (1 vs 8, 4 vs 5, 2 vs 7, 3 vs 6) + // Usamos el seeding oficial: 1v8, 4v5, 2v7, 3v6 + $parejas = [ + ['local' => $top8[0]['id'], 'visit' => $top8[7]['id'] ?? null, 'nro' => 1], + ['local' => $top8[3]['id'] ?? null, 'visit' => $top8[4]['id'] ?? null, 'nro' => 2], + ['local' => $top8[1]['id'] ?? null, 'visit' => $top8[6]['id'] ?? null, 'nro' => 3], + ['local' => $top8[2]['id'] ?? null, 'visit' => $top8[5]['id'] ?? null, 'nro' => 4], + ]; + + $count = 0; + foreach ($parejas as $p) { + if (!$p['local'] || !$p['visit']) continue; + + $local = \App\Models\Equipo::with('club')->find($p['local']); + $visit = \App\Models\Equipo::with('club')->find($p['visit']); + + // Crear N partidos según el formato + for ($i = 0; $i < $formato; $i++) { + \App\Models\Evento::create([ + 'id_evento' => uniqid('ply_'), + 'id_torneo' => $id, + 'fase' => \App\Models\Evento::FASE_CUARTOS, + 'numero_partido_bracket' => $p['nro'], + 'id_equipo_local' => $p['local'], + 'id_equipo_visitante' => $p['visit'], + 'nombre_evento' => "4tos (J".($i+1)."): " . $local->club->nombre . " vs " . $visit->club->nombre . " ({$grupo})", + 'fecha_evento' => now()->addDays(7 + ($i * 3))->format('Y-m-d'), + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'precio' => 0, + ]); + $count++; + } + } + + return redirect()->route('admin.torneos.playoffs.manage', $id) + ->with('admin_msg', "Playoffs generados: {$count} partidos creados para el grupo {$grupo}."); + } + + public function managePlayoffs(int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::findOrFail($id); + + $ts = new \App\Services\TournamentService(); + $bracket = $ts->getPlayoffBrackets($id); + + $grupos = \DB::table('torneo_equipo')->where('id_torneo', $id)->distinct()->pluck('grupo')->filter(); + + return view('admin.torneos.playoff_manage', compact('torneo', 'bracket', 'grupos')); + } + + public function avanzarGanador(Request $request, int $id) + { + $this->checkSuperAdmin(); + $faseActual = (int) $request->input('fase'); + $nroBracket = (int) $request->input('nro_bracket'); + $idGanador = (int) $request->input('id_ganador'); + + $torneo = Torneo::findOrFail($id); + $ganador = \App\Models\Equipo::with('club')->findOrFail($idGanador); + + // Mapeo de avance + // Cuartos 1 & 2 -> Semis 1 + // Cuartos 3 & 4 -> Semis 2 + // Semis 1 & 2 -> Final 1 + $mapping = [ + \App\Models\Evento::FASE_CUARTOS => [ + 1 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 1, 'side' => 'local'], + 2 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 1, 'side' => 'visitante'], + 3 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 2, 'side' => 'local'], + 4 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 2, 'side' => 'visitante'], + ], + \App\Models\Evento::FASE_SEMIS => [ + 1 => ['next_fase' => \App\Models\Evento::FASE_FINAL, 'next_nro' => 1, 'side' => 'local'], + 2 => ['next_fase' => \App\Models\Evento::FASE_FINAL, 'next_nro' => 1, 'side' => 'visitante'], + ] + ]; + + if (!isset($mapping[$faseActual][$nroBracket])) { + return back()->with('admin_error', 'No se puede avanzar desde esta fase.'); + } + + $next = $mapping[$faseActual][$nroBracket]; + + // Buscar si ya existe el partido en la siguiente fase + $eventoNext = \App\Models\Evento::where('id_torneo', $id) + ->where('fase', $next['next_fase']) + ->where('numero_partido_bracket', $next['next_nro']) + ->first(); + + if ($eventoNext) { + if ($next['side'] == 'local') { + $eventoNext->id_equipo_local = $idGanador; + } else { + $eventoNext->id_equipo_visitante = $idGanador; + } + $eventoNext->nombre_evento = ($eventoNext->equipoLocal->club->nombre ?? 'TBD') . ' vs ' . ($eventoNext->equipoVisitante->club->nombre ?? 'TBD'); + $eventoNext->save(); + } else { + // Crear el primer partido de la serie (por defecto 1 partido, el admin puede agregar más) + \App\Models\Evento::create([ + 'id_evento' => uniqid('ply_adv_'), + 'id_torneo' => $id, + 'fase' => $next['next_fase'], + 'numero_partido_bracket' => $next['next_nro'], + 'id_equipo_local' => $next['side'] == 'local' ? $idGanador : null, + 'id_equipo_visitante' => $next['side'] == 'visitante' ? $idGanador : null, + 'nombre_evento' => $next['side'] == 'local' ? ($ganador->club->nombre . " vs TBD") : ("TBD vs " . $ganador->club->nombre), + 'fecha_evento' => now()->addDays(14)->format('Y-m-d'), + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'precio' => 0, + ]); + } + + return back()->with('admin_msg', "✅ Equipo {$ganador->club->nombre} avanzado a la siguiente ronda."); + } +} diff --git a/app/Http/Controllers/Admin/PaseController.php b/app/Http/Controllers/Admin/PaseController.php new file mode 100644 index 0000000..e05d4c5 --- /dev/null +++ b/app/Http/Controllers/Admin/PaseController.php @@ -0,0 +1,129 @@ +checkGeneralAdmin($request); + + $query = Pase::with(['jugador', 'clubOrigen', 'clubDestino']); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $query->where(function ($q) use ($idClub) { + $q->where('id_club_origen', $idClub) + ->orWhere('id_club_destino', $idClub); + }); + } + + $pases = $query->orderBy('created_at', 'desc')->paginate(20); + return view('admin.pases.index', compact('pases')); + } + + public function create(Request $request) + { + $this->checkGeneralAdmin($request); + return view('admin.pases.create'); + } + + public function store(Request $request) + { + $this->checkGeneralAdmin($request); + + $request->validate([ + 'documento' => 'required|string|exists:jugadores,documento' + ], [ + 'documento.exists' => 'No se encontró un jugador con ese DNI.' + ]); + + $jugador = Jugador::where('documento', $request->documento)->first(); + + // Si el jugador ya pertenece al club destino, error + $idClubDestino = session('admin_role') == 2 ? session('admin_id_club') : $request->input('id_club_destino'); + + if (!$idClubDestino) { + return back()->withErrors(['id_club_destino' => 'Debe especificar el club destino.']); + } + + if ($jugador->id_club_actual == $idClubDestino) { + return back()->withErrors(['documento' => 'Este jugador ya pertenece a tu club.']); + } + + // Crear la petición de pase + Pase::create([ + 'id_jugador' => $jugador->id_jugador, + 'id_club_origen' => $jugador->id_club_actual, + 'id_club_destino' => $idClubDestino, + 'estado' => 'Pendiente' + ]); + + return redirect()->route('admin.pases.index')->with('admin_msg', 'Solicitud de pase creada correctamente y está pendiente de aprobación.'); + } + + public function aprobar(Request $request, $id) + { + $this->checkSuperAdmin($request); + $pase = Pase::findOrFail($id); + + if ($pase->estado !== 'Pendiente') { + return back()->withErrors(['pase' => 'Este pase ya fue procesado.']); + } + + $pase->update(['estado' => 'Aprobado']); + + // Actualizar jugador + if ($pase->jugador) { + // Desvincular de equipos del club de origen + $equiposOldClubIds = $pase->jugador->equipos() + ->where('id_club', $pase->id_club_origen) + ->pluck('equipos.id_equipo'); + + if ($equiposOldClubIds->count() > 0) { + $pase->jugador->equipos()->detach($equiposOldClubIds); + } + + $pase->jugador->update([ + 'id_club_origen' => $pase->id_club_origen, + 'id_club_actual' => $pase->id_club_destino + ]); + } + + return redirect()->route('admin.pases.index')->with('admin_msg', 'Pase aprobado correctamente y jugador desvinculado de equipos anteriores.'); + } + + public function rechazar(Request $request, $id) + { + $this->checkSuperAdmin($request); + $pase = Pase::findOrFail($id); + + if ($pase->estado !== 'Pendiente') { + return back()->withErrors(['pase' => 'Este pase ya fue procesado.']); + } + + $pase->update(['estado' => 'Rechazado']); + + return redirect()->route('admin.pases.index')->with('admin_msg', 'Pase rechazado.'); + } +} diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php new file mode 100644 index 0000000..10de9e6 --- /dev/null +++ b/app/Http/Controllers/AdminController.php @@ -0,0 +1,1667 @@ +checkGeneralAdmin($request); + + if (session('admin_role') == 1) { + $stats = [ + 'clubes' => Club::count(), + 'equipos' => Equipo::count(), + 'jugadores' => Jugador::count(), + 'eventos' => Evento::count(), + 'promociones' => Promocion::count(), + 'noticias' => Noticia::count(), + ]; + $miClub = null; + } else { + $idClub = session('admin_id_club'); + $miClub = Club::find($idClub); + + $stats = [ + 'equipos' => Equipo::where('id_club', $idClub)->count(), + 'jugadores' => Jugador::where('id_club_actual', $idClub)->count(), + 'eventos' => Evento::whereHas('equipoLocal', function($q) use ($idClub) { + $q->where('id_club', $idClub); + })->orWhereHas('equipoVisitante', function($q) use ($idClub) { + $q->where('id_club', $idClub); + })->count(), + 'promociones' => Promocion::count(), // Promociones son generales + ]; + } + + return view('admin.dashboard', compact('stats', 'miClub')); + } + + // ══════════════════════════════════ + // CLUBES + // ══════════════════════════════════ + public function clubesIndex(Request $request) + { + $this->checkSuperAdmin($request); + $search = trim((string) $request->input('q', '')); + + // Recién creados/editados primero; registros viejos sin timestamps caen al fallback por id. + $query = Club::withCount('equipos', 'jugadores') + ->orderByRaw("COALESCE(updated_at, '1970-01-01') DESC") + ->orderBy('id_club', 'desc'); + + if ($search !== '') { + $query->where('nombre', 'like', '%' . $search . '%'); + } + + $clubes = $query->get(); + return view('admin.clubes.index', compact('clubes', 'search')); + } + + public function clubesCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.clubes.form', ['club' => null]); + } + + public function clubesStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'id_club' => 'nullable|integer', + 'nombre' => 'required|string|max:100', + 'es_seleccion' => 'nullable|boolean', + ]); + $data['es_seleccion'] = $request->boolean('es_seleccion'); + Club::create($data); + return redirect()->route('admin.clubes.index')->with('admin_msg', 'Club creado correctamente.'); + } + + public function clubesEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + if (session('admin_role') == 2 && session('admin_id_club') != $id) { + abort(403, 'No tienes permiso para editar este club.'); + } + $club = Club::findOrFail($id); + return view('admin.clubes.form', compact('club')); + } + + public function clubesUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + if (session('admin_role') == 2 && session('admin_id_club') != $id) { + abort(403, 'No tienes permiso para editar este club.'); + } + + $club = Club::findOrFail($id); + + $rules = [ + 'qr_color_texto' => 'nullable|string|max:20', + 'qr_background' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048', + 'logo_club' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:1024' + ]; + + if (session('admin_role') == 1) { + $rules['nombre'] = 'required|string|max:100'; + $rules['es_seleccion'] = 'nullable|boolean'; + } + + $data = $request->validate($rules); + + if (session('admin_role') == 1) { + $data['es_seleccion'] = $request->boolean('es_seleccion'); + } + + // Manejo de Logo del Club + if ($request->hasFile('logo_club')) { + // Eliminar logo anterior si existe + if ($club->imagen && file_exists(public_path($club->imagen))) { + @unlink(public_path($club->imagen)); + } + $logoPath = app(ImageOptimizer::class)->storeAndOptimize($request->file('logo_club'), 'clubes'); + $data['imagen'] = 'storage/' . $logoPath; + } + + // Manejo de Fondo QR + if ($request->hasFile('qr_background')) { + // Eliminar fondo anterior si existe + if ($club->qr_background && file_exists(public_path($club->qr_background))) { + @unlink(public_path($club->qr_background)); + } + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('qr_background'), 'qr'); + $data['qr_background'] = 'storage/' . $path; + } + + $club->update($data); + + if (session('admin_role') == 2) { + return back()->with('admin_msg', 'Plantilla de QR actualizada correctamente.'); + } + + return redirect()->route('admin.clubes.index')->with('admin_msg', 'Club actualizado correctamente.'); + } + + public function clubesDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $club = Club::findOrFail($id); + $club->delete(); + return redirect()->route('admin.clubes.index')->with('admin_msg', 'Club eliminado correctamente.'); + } + + // ══════════════════════════════════ + // EQUIPOS + // ══════════════════════════════════ + public function equiposIndex(Request $request) + { + $this->checkGeneralAdmin($request); + $search = trim((string) $request->input('q', '')); + + $query = Equipo::with('club')->withCount('jugadores') + ->orderByRaw("COALESCE(updated_at, '1970-01-01') DESC") + ->orderBy('id_equipo', 'desc'); + + if (session('admin_role') == 2) { + $query->where('id_club', session('admin_id_club')); + } + + if ($search !== '') { + $like = '%' . $search . '%'; + $query->where(function ($q) use ($like, $search) { + $q->where('categoria', 'like', $like) + ->orWhere('division', 'like', $like) + ->orWhereHas('club', function ($c) use ($like) { + $c->where('nombre', 'like', $like); + }); + + if (ctype_digit($search)) { + $q->orWhere('id_equipo', (int) $search); + } + }); + } + + $equipos = $query->get(); + return view('admin.equipos.index', compact('equipos', 'search')); + } + + public function equiposCreate(Request $request) + { + $this->checkGeneralAdmin($request); + + if (session('admin_role') == 1) { + $clubes = Club::orderBy('nombre')->get(); + } else { + $clubes = Club::where('id_club', session('admin_id_club'))->get(); + } + + $categorias = \App\Models\Categoria::orderBy('nombre')->get(); + return view('admin.equipos.form', ['equipo' => null, 'clubes' => $clubes, 'categorias' => $categorias]); + } + + public function equiposStore(Request $request) + { + $this->checkGeneralAdmin($request); + + $data = $request->validate([ + 'id_club' => session('admin_role') == 1 ? 'required|integer|exists:clubes,id_club' : 'nullable', + 'categoria' => 'required|string|max:20', + 'division' => 'required|string|max:5', + ]); + + if (session('admin_role') == 2) { + $data['id_club'] = session('admin_id_club'); + } + + Equipo::create($data); + return redirect()->route('admin.equipos.index')->with('admin_msg', 'Equipo creado correctamente.'); + } + + public function equiposEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para editar este equipo.'); + } + + if (session('admin_role') == 1) { + $clubes = Club::orderBy('nombre')->get(); + } else { + $clubes = Club::where('id_club', session('admin_id_club'))->get(); + } + + $categorias = \App\Models\Categoria::orderBy('nombre')->get(); + return view('admin.equipos.form', compact('equipo', 'clubes', 'categorias')); + } + + public function equiposUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para editar este equipo.'); + } + + $data = $request->validate([ + 'id_club' => session('admin_role') == 1 ? 'required|integer|exists:clubes,id_club' : 'nullable', + 'categoria' => 'required|string|max:20', + 'division' => 'required|string|max:5', + ]); + + if (session('admin_role') == 2) { + $data['id_club'] = session('admin_id_club'); + } + + $equipo->update($data); + return redirect()->route('admin.equipos.index')->with('admin_msg', 'Equipo actualizado correctamente.'); + } + + public function equiposDestroy(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para eliminar este equipo.'); + } + + $equipo->delete(); + return redirect()->route('admin.equipos.index')->with('admin_msg', 'Equipo eliminado correctamente.'); + } + + public function equipoJugadores($id) + { + $this->checkGeneralAdmin(request()); + $equipo = Equipo::with('club', 'jugadores')->findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para gestionar jugadores de este equipo.'); + } + + return view('admin.equipos.jugadores', compact('equipo')); + } + + public function equipoAddJugador(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403); + } + + $data = $request->validate([ + 'id_jugador' => 'required|string|exists:jugadores,id_jugador', + ]); + + // Verificar que el jugador sea del mismo club, salvo que el equipo pertenezca a una selección + $jugador = Jugador::where('id_jugador', $data['id_jugador'])->firstOrFail(); + $esSeleccion = $equipo->club && $equipo->club->es_seleccion; + if (!$esSeleccion && $jugador->id_club_actual != $equipo->id_club) { + return back()->with('admin_error', 'El jugador no pertenece al mismo club que el equipo.'); + } + + // Evitar duplicados + if ($equipo->jugadores()->where('jugador_equipo.id_jugador', $data['id_jugador'])->exists()) { + return back()->with('admin_error', 'El jugador ya está asignado a este equipo.'); + } + + $equipo->jugadores()->attach($data['id_jugador'], ['fecha_alta' => now()]); + + return back()->with('admin_msg', 'Jugador asignado correctamente.'); + } + + public function equipoRemoveJugador($id, $id_jugador) + { + $this->checkGeneralAdmin(request()); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403); + } + + $equipo->jugadores()->detach($id_jugador); + + return back()->with('admin_msg', 'Jugador removido del equipo.'); + } + + public function jugadoresSearchAjax(Request $request) + { + $this->checkGeneralAdmin($request); + $q = $request->input('q'); + + $query = Jugador::query(); + + if (session('admin_role') == 2) { + $query->where('id_club_actual', session('admin_id_club')); + } + + if ($q) { + $query->where(function($sub) use ($q) { + $sub->where('nombre', 'like', "%$q%") + ->orWhere('apellido', 'like', "%$q%") + ->orWhere('documento', 'like', "%$q%"); + }); + } + + $jugadores = $query->limit(10)->get(['id_jugador', 'nombre', 'apellido', 'documento']); + + return response()->json($jugadores); + } + + public function jugadoresCategoriaPorEdad(Request $request) + { + $this->checkGeneralAdmin($request); + $fecha = $request->input('fecha'); + if (!$fecha) return response()->json(['categoria' => 'Sin categoría']); + + $anio = \Carbon\Carbon::parse($fecha)->format('Y'); + $edadCategoria = date('Y') - $anio; + + $categoria = \App\Models\Categoria::where('edad_min', '<=', $edadCategoria) + ->where('edad_max', '>=', $edadCategoria) + ->first(); + + return response()->json(['categoria' => $categoria ? $categoria->nombre : 'Sin categoría']); + } + + + // ══════════════════════════════════ + // JUGADORES + // ══════════════════════════════════ + public function jugadoresIndex(Request $request) + { + $this->checkGeneralAdmin($request); + $search = $request->input('q'); + $query = Jugador::with('clubActual') + ->orderByRaw("COALESCE(updated_at, '1970-01-01') DESC") + ->orderByRaw('CAST(id_jugador AS UNSIGNED) DESC'); + + if (session('admin_role') == 2) { + $query->where('id_club_actual', session('admin_id_club')); + } + + if ($search) { + // Tokenizar por espacios. Cada token debe matchear como INICIO de palabra + // dentro de "apellido nombre" — así "Man Adriel" encuentra a MAN ADRIEL pero + // no a ROMAN PEREZ. El DNI sigue aceptando match por substring directo. + $tokens = array_values(array_filter( + array_map( + fn($t) => preg_replace('/[^\p{L}\p{N}]/u', '', $t), + preg_split('/\s+/u', $search) + ), + fn($t) => mb_strlen($t) > 0 + )); + + $query->where(function ($q) use ($tokens, $search) { + $q->where('documento', 'like', '%' . $search . '%'); + + if (!empty($tokens)) { + $q->orWhere(function ($qq) use ($tokens) { + foreach ($tokens as $token) { + $qq->whereRaw( + "LOWER(CONCAT(apellido, ' ', nombre)) REGEXP ?", + ['[[:<:]]' . mb_strtolower($token)] + ); + } + }); + } + }); + } + + $jugadores = $query->paginate(25); + $clubes = session('admin_role') == 1 ? Club::orderBy('nombre')->get() : []; + + return view('admin.jugadores.index', compact('jugadores', 'search', 'clubes')); + } + + public function jugadoresCreate(Request $request) + { + $this->checkGeneralAdmin($request); + $clubes = Club::orderBy('nombre')->get(); + return view('admin.jugadores.form', ['jugador' => null, 'clubes' => $clubes]); + } + + public function jugadoresStore(Request $request) + { + $this->checkGeneralAdmin($request); + $data = $request->validate([ + 'documento' => 'required|string|max:20', + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'fecha_nacimiento' => 'required|date', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'required|integer|exists:clubes,id_club', + ]); + + // Verificar si el DNI ya existe. withTrashed: el índice UNIQUE de la BD considera + // también soft-deleted, así que la validación debe contemplarlos para no caer en error 1062. + $existente = Jugador::withTrashed()->where('documento', $data['documento'])->first(); + if ($existente) { + if ($existente->trashed()) { + return back()->withInput()->withErrors([ + 'documento' => "Este DNI pertenece a un jugador que está en la papelera. Restauralo desde allí o contactá al superadmin." + ]); + } + $clubNombre = $existente->clubActual ? $existente->clubActual->nombre : 'Sin Club'; + return back()->withInput()->withErrors([ + 'documento' => "No se puede registrar al jugador dado que ya pertenece al club $clubNombre." + ]); + } + + if (session('admin_role') == 2) { + $data['id_club_actual'] = session('admin_id_club'); + // Nota: id_club_origen podría ser el mismo o diferente, + // pero el Id del jugador siempre se basa en el de origen. + } + + $data['nombre'] = strtoupper(trim($data['nombre'])); + $data['apellido'] = strtoupper(trim($data['apellido'])); + $data['activo'] = 0; // Se valida luego en /asociate + if (isset($data['fecha_nacimiento'])) { + $data['edad'] = \Carbon\Carbon::parse($data['fecha_nacimiento'])->age; + // $data['categoria'] ya no se asigna, ahora es dinámica + } + + $idClub = $data['id_club_origen']; + $yearFull = \Carbon\Carbon::parse($data['fecha_nacimiento'])->format('Y'); + $data['id_jugador'] = $this->generarIdJugador($idClub, $yearFull); + + Jugador::create($data); + return redirect()->route('admin.jugadores.index')->with('admin_msg', 'Jugador creado correctamente. Puede activarse en /asociate.'); + } + + private function generarIdJugador($idClub, $yearFull) + { + $yearShort = \Carbon\Carbon::parse($yearFull . '-01-01')->format('y'); + $prefix = $idClub . $yearShort; + $secuencia = $this->obtenerSiguienteSecuencia($idClub, $yearFull, $prefix); + + return sprintf('%s%02d', $prefix, $secuencia); + } + + private function obtenerSiguienteSecuencia($idClub, $yearFull, $prefix) + { + // withTrashed: incluir jugadores soft-deleted para no reusar IDs y chocar con la PK. + $ultimoId = (string)Jugador::withTrashed() + ->where('id_jugador', 'LIKE', $prefix . '%') + ->whereRaw("id_jugador REGEXP '^[0-9]+$'") + ->orderByRaw('CAST(id_jugador AS UNSIGNED) DESC') + ->value('id_jugador'); + + $secuencia = 1; + if ($ultimoId && str_starts_with($ultimoId, $prefix)) { + $secuenciaStr = substr($ultimoId, strlen($prefix)); + $secuencia = (int)$secuenciaStr + 1; + } + return $secuencia; + } + + public function jugadoresImport(Request $request) + { + $this->checkGeneralAdmin($request); + $request->validate([ + 'csv_file' => 'required|file|mimes:csv,txt', + 'id_club' => session('admin_role') == 1 ? 'nullable|integer' : 'nullable' + ]); + + $file = $request->file('csv_file'); + $handle = fopen($file->getRealPath(), 'r'); + + $successCount = 0; + $omittedCount = 0; + $errorCount = 0; + $teamAssignedCount = 0; + $errors = []; + + // Determinar Club Target + $targetClubId = session('admin_role') == 2 ? session('admin_id_club') : ($request->input('id_club') ?? 99); + + $currentCategory = null; + $formatType = 'legacy'; // legacy, cab, internal + $localSequences = []; // Cache para evitar duplicados en el mismo loop + + // Leer primeras líneas para detectar formato (el CAB puede empezar con una categoría) + $detectLines = []; + for ($i=0; $i<5; $i++) { + $l = fgets($handle); + if ($l) $detectLines[] = $l; + } + rewind($handle); + + $fullContentSample = implode("\n", $detectLines); + if (str_contains($fullContentSample, 'ESTADO LICENCIA') || str_contains($fullContentSample, 'TIPO') || str_contains($fullContentSample, 'JUGADOR')) { + $formatType = 'cab'; + } elseif (str_contains($fullContentSample, 'DNI;Apellido;Nombre;Fecha Nacimiento')) { + $formatType = 'internal'; + } + + while (($row = fgetcsv($handle, 1000, ";")) !== FALSE) { + if (empty(array_filter($row))) continue; + + // --- DETECCION DE CATEGORIA (Solo CAB) --- + // Si la primera columna tiene texto y el resto está vacío, es una categoría (ej: "PREMINI B;;;;;") + if ($formatType == 'cab' && !empty($row[0]) && empty(array_filter(array_slice($row, 1)))) { + $currentCategory = trim($row[0]); + continue; + } + + // Saltos de cabecera + if ($formatType == 'cab' && $row[0] == 'ESTADO LICENCIA') continue; + if ($formatType == 'internal' && $row[0] == 'DNI') continue; + + try { + $dni = ''; $apellido = ''; $nombre = ''; $fechaNac = ''; + $idClubOrigen = $targetClubId; + $idClubActual = $targetClubId; + $isJugador = true; + + if ($formatType == 'cab') { + // Formato: ESTADO LICENCIA;NIF;NOMBRE;FECHA_ALTA;BAJA;TIPO;FECHA NACIMIENTO;NACIONALIDAD + if (count($row) < 7) continue; + $dni = trim($row[1]); + $fullName = trim($row[2]); // "APELLIDO, NOMBRE" + $tipo = strtoupper(trim($row[5])); + $fechaRaw = trim($row[6]); // d/m/Y + + if ($tipo !== 'JUGADOR') { + $isJugador = false; + } + + if ($isJugador) { + $parts = explode(',', $fullName); + $apellido = trim($parts[0]); + $nombre = isset($parts[1]) ? trim($parts[1]) : ''; + + // Parsear d/m/Y + $dateParts = explode('/', $fechaRaw); + if (count($dateParts) == 3) { + $fechaNac = "{$dateParts[2]}-{$dateParts[1]}-{$dateParts[0]}"; + } + } + } elseif ($formatType == 'internal') { + // Formato: DNI; Apellido; Nombre; Fecha Nacimiento (d/m/Y); ID Club Origen; ID Club Actual; Categoria; Activo + $dni = trim($row[0]); + $apellido = trim($row[1]); + $nombre = trim($row[2]); + $fechaRaw = trim($row[3]); + $idClubOrigen = isset($row[4]) ? (int)trim($row[4]) : $targetClubId; + $idClubActual = isset($row[5]) ? (int)trim($row[5]) : $targetClubId; + + $dateParts = explode('/', $fechaRaw); + if (count($dateParts) == 3) { + $fechaNac = "{$dateParts[2]}-{$dateParts[1]}-{$dateParts[0]}"; + } + } else { + // Formato Legado: DNI; Apellido; Nombre; ddmmaaaa; id_club_origen + if (count($row) < 4) continue; + $dni = trim($row[0]); + + // SEGURIDAD: Si el DNI no es numérico en formato legado, probablemente sea una cabecera o ruido + if (!is_numeric($dni)) continue; + + $apellido = trim($row[1]); + $nombre = trim($row[2]); + $fechaRaw = trim($row[3]); // ddmmaaaa + $idClubOrigen = isset($row[4]) ? (int)trim($row[4]) : $targetClubId; + + if (strlen($fechaRaw) == 8) { + $fechaNac = substr($fechaRaw, 4, 4) . "-" . substr($fechaRaw, 2, 2) . "-" . substr($fechaRaw, 0, 2); + } + } + + if (!$isJugador || !$dni || !$apellido || !$nombre || !$fechaNac) continue; + + // Verificar existencia + $jugador = Jugador::where('documento', $dni)->first(); + $anioNac = date('Y', strtotime($fechaNac)); + + $data = [ + 'documento' => $dni, + 'apellido' => strtoupper(trim($apellido)), + 'nombre' => strtoupper(trim($nombre)), + 'fecha_nacimiento' => $fechaNac, + 'id_club_origen' => $idClubOrigen, + 'id_club_actual' => $idClubActual, + 'edad' => \Carbon\Carbon::parse($fechaNac)->age, + ]; + + if (!$jugador) { + // Generar ID con cache local para evitar colisiones en el mismo loop + $prefix = $idClubOrigen . date('y', strtotime($fechaNac)); + if (!isset($localSequences[$prefix])) { + // Obtener la base inicial de la BDD + $localSequences[$prefix] = $this->obtenerSiguienteSecuencia($idClubOrigen, $anioNac, $prefix); + } else { + $localSequences[$prefix]++; + } + + $data['id_jugador'] = sprintf('%s%02d', $prefix, $localSequences[$prefix]); + $data['activo'] = 0; + $jugador = Jugador::create($data); + $successCount++; + } else { + // El usuario prefiere NO pisar datos si el jugador ya existe + $omittedCount++; + } + + // --- MATCHING DE EQUIPO --- + if ($currentCategory && $jugador) { + $equipo = Equipo::where('id_club', $idClubActual) + ->where('categoria', 'LIKE', $currentCategory) + ->first(); + if ($equipo) { + // Evitar duplicados en pivot + if (!$equipo->jugadores()->where('jugador_equipo.id_jugador', $jugador->id_jugador)->exists()) { + $equipo->jugadores()->attach($jugador->id_jugador, ['fecha_alta' => now()]); + $teamAssignedCount++; + } + } + } + + } catch (\Exception $e) { + $errorCount++; + $errors[] = "Error en fila DNI $dni: " . $e->getMessage(); + } + } + fclose($handle); + + $msg = "Importación finalizada ({$formatType}). $successCount nuevos creados, $omittedCount ya registrados (no modificados)."; + if ($teamAssignedCount > 0) $msg .= " $teamAssignedCount asignaciones a equipos realizadas."; + + if ($errorCount > 0) { + return redirect()->route('admin.jugadores.index')->with('admin_msg', $msg)->with('admin_error', implode(" | ", array_slice($errors, 0, 5))); + } + + return redirect()->route('admin.jugadores.index')->with('admin_msg', $msg); + } + + public function jugadoresExport(Request $request) + { + $this->checkGeneralAdmin($request); + + $headers = [ + "Content-type" => "text/csv; charset=UTF-8", + "Content-Disposition" => "attachment; filename=jugadores_" . date('Ymd_His') . ".csv", + "Pragma" => "no-cache", + "Cache-Control" => "must-revalidate, post-check=0, pre-check=0", + "Expires" => "0" + ]; + + $callback = function() { + $file = fopen('php://output', 'w'); + // Añadir BOM para Excel (UTF-8) + fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); + + // Cabeceras + fputcsv($file, [ + 'DNI', 'Apellido', 'Nombre', 'Fecha Nacimiento', 'ID Club Origen', 'ID Club Actual', 'Categoria', 'Activo' + ], ";"); + + $query = Jugador::orderBy('apellido'); + + if (session('admin_role') == 2) { + $query->where('id_club_actual', session('admin_id_club')); + } + + $jugadores = $query->get(); + + foreach ($jugadores as $j) { + fputcsv($file, [ + $j->documento, + $j->apellido, + $j->nombre, + $j->fecha_nacimiento ? $j->fecha_nacimiento->format('d/m/Y') : '', + $j->id_club_origen ?? 99, + $j->id_club_actual ?? 99, + $j->categoria_calculada, + $j->activo ? 'SI' : 'NO' + ], ";"); + } + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } + + public function jugadoresEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $jugador = Jugador::findOrFail($id); + + if (session('admin_role') == 2 && $jugador->id_club_actual != session('admin_id_club')) { + abort(403, 'No puedes editar un jugador que no pertenece a tu club.'); + } + + $clubes = session('admin_role') == 1 ? Club::orderBy('nombre')->get() : []; + return view('admin.jugadores.form', compact('jugador', 'clubes')); + } + + public function jugadoresUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $jugador = Jugador::findOrFail($id); + + if (session('admin_role') == 2 && $jugador->id_club_actual != session('admin_id_club')) { + abort(403, 'No puedes editar un jugador que no pertenece a tu club.'); + } + + $data = $request->validate([ + 'documento' => 'required|string|max:20|unique:jugadores,documento,' . $id . ',id_jugador', + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'fecha_nacimiento' => 'required|date', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'nullable|integer|exists:clubes,id_club', + ]); + + if (session('admin_role') == 2) { + $data['id_club_actual'] = session('admin_id_club'); // Forzamos a no cambiarlo + } + + if (isset($data['fecha_nacimiento'])) { + $data['edad'] = \Carbon\Carbon::parse($data['fecha_nacimiento'])->age; + } + + $jugador->update($data); + return redirect()->route('admin.jugadores.index')->with('admin_msg', 'Jugador actualizado correctamente.'); + } + + public function jugadoresDestroy(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $jugador = Jugador::findOrFail($id); + + if (session('admin_role') == 2 && $jugador->id_club_actual != session('admin_id_club')) { + abort(403, 'No tienes permiso para eliminar este jugador.'); + } + + $jugador->delete(); + return redirect()->route('admin.jugadores.index')->with('admin_msg', 'Jugador eliminado correctamente.'); + } + + // ══════════════════════════════════ + // EVENTOS + // ══════════════════════════════════ + public function escanearQr(Request $request) + { + $this->checkGeneralAdmin($request); + + $query = Evento::orderByRaw(" + CAST(SUBSTRING_INDEX(nombre_evento, 'PARTIDO ', -1) AS UNSIGNED) ASC, + nombre_evento ASC, + fecha_evento ASC, + hora_inicio ASC + "); + + $ahora = \Carbon\Carbon::now(); + $query->where(function($q) use ($ahora) { + $q->where(function($sub) { + $sub->whereNull('marcador_local') + ->orWhereNull('marcador_visitante'); + }) + ->orWhere('fecha_evento', '>', $ahora->toDateString()) + ->orWhere(function($q2) use ($ahora) { + $q2->where('fecha_evento', '=', $ahora->toDateString()) + ->where('hora_fin', '>', $ahora->toTimeString()); + }); + }); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $query->where(function ($q) use ($idClub) { + $q->whereHas('equipoLocal', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + })->orWhereHas('equipoVisitante', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + }); + }); + } + + $eventos = $query->get(); + return view('admin.escanear_qr', compact('eventos')); + } + + public function eventosIndex(Request $request) + { + $this->checkGeneralAdmin($request); + + $estado = $request->get('estado', 'todos'); + $query = Evento::with(['equipoLocal.club', 'equipoVisitante.club'])->orderBy('fecha_evento', 'desc'); + + $tz = 'America/Argentina/Buenos_Aires'; + $ahora = \Carbon\Carbon::now($tz); + + // Filtro por estado + if ($estado == 'finalizados') { + $query->whereNotNull('marcador_local') + ->whereNotNull('marcador_visitante') + ->where(function($q) use ($ahora) { + $q->where('fecha_evento', '<', $ahora->toDateString()) + ->orWhere(function($q2) use ($ahora) { + $q2->where('fecha_evento', '=', $ahora->toDateString()) + ->where('hora_fin', '<=', $ahora->toTimeString()); + }); + }); + } elseif ($estado == 'pendientes') { + $query->where(function($q) use ($ahora) { + $q->whereNull('marcador_local') + ->orWhereNull('marcador_visitante') + ->orWhere('fecha_evento', '>', $ahora->toDateString()) + ->orWhere(function($q2) use ($ahora) { + $q2->where('fecha_evento', '=', $ahora->toDateString()) + ->where('hora_fin', '>', $ahora->toTimeString()); + }); + }); + } + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $query->where(function($q) use ($idClub) { + $q->whereHas('equipoLocal', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + })->orWhereHas('equipoVisitante', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + }); + }); + } + + $eventos = $query->orderBy('id_evento', 'desc')->get(); + return view('admin.eventos.index', compact('eventos', 'estado')); + } + + public function eventosCreate(Request $request) + { + $this->checkSuperAdmin($request); + $equipos = Equipo::with('club')->get(); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + $torneoEquipos = DB::table('torneo_equipo')->get(); + return view('admin.eventos.form', ['evento' => null, 'equipos' => $equipos, 'torneos' => $torneos, 'torneoEquipos' => $torneoEquipos]); + } + + public function eventosStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre_evento' => 'nullable|string|max:200', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + 'fecha_evento' => 'required|date', + 'hora_inicio' => 'required', + 'hora_fin' => 'required', + 'sede' => 'required|string|max:200', + 'id_equipo_local' => 'required|integer|exists:equipos,id_equipo', + 'id_equipo_visitante' => 'required|integer|exists:equipos,id_equipo', + 'precio' => 'nullable|numeric|min:0', + 'marcador_local' => 'nullable|integer|min:0', + 'marcador_visitante' => 'nullable|integer|min:0', + ]); + + // Validaciones Deportivas + $local = Equipo::findOrFail($data['id_equipo_local']); + $visit = Equipo::findOrFail($data['id_equipo_visitante']); + + if ($local->categoria != $visit->categoria) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer a la misma categoría.']); + } + + if (!empty($data['id_torneo'])) { + $torneo = \App\Models\Torneo::findOrFail($data['id_torneo']); + $localInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_local'])->first(); + $visitInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_visitante'])->first(); + + if (!$localInTorneo || !$visitInTorneo) { + return back()->withInput()->withErrors(['id_torneo' => 'Uno o ambos equipos no están inscritos en este torneo.']); + } + + if ($localInTorneo->pivot->grupo != $visitInTorneo->pivot->grupo) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer al mismo grupo dentro del torneo.']); + } + } + + // Autogenerar ID + $data['id_evento'] = bin2hex(random_bytes(4)); // 8 caracteres + + if (empty($data['nombre_evento'])) { + $grupoName = 'General'; + if (!empty($data['id_torneo'])) { + $rel = DB::table('torneo_equipo') + ->where('id_torneo', $data['id_torneo']) + ->where('id_equipo', $data['id_equipo_local']) + ->first(); + if ($rel) $grupoName = $rel->grupo ?? 'General'; + } + $data['nombre_evento'] = ($local->club->nombre ?? 'Local') . ' vs ' . ($visit->club->nombre ?? 'Visitante') . " ({$grupoName})"; + } + + Evento::create($data); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Evento creado correctamente.'); + } + + public function eventosEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $evento = Evento::findOrFail($id); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $isLocal = $evento->equipoLocal && $evento->equipoLocal->id_club == $idClub; + $isVisitante = $evento->equipoVisitante && $evento->equipoVisitante->id_club == $idClub; + if (!$isLocal && !$isVisitante) { + abort(403, 'No tienes permiso para editar este evento.'); + } + } + $equipos = Equipo::with('club')->get(); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + $torneoEquipos = DB::table('torneo_equipo')->get(); + return view('admin.eventos.form', compact('evento', 'equipos', 'torneos', 'torneoEquipos')); + } + + public function eventosUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $evento = Evento::findOrFail($id); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $isLocal = $evento->equipoLocal && $evento->equipoLocal->id_club == $idClub; + $isVisitante = $evento->equipoVisitante && $evento->equipoVisitante->id_club == $idClub; + if (!$isLocal && !$isVisitante) { + abort(403, 'No tienes permiso para editar este evento.'); + } + + // Club admins only edit limite_qr_jugador + $data = $request->validate([ + 'limite_qr_jugador' => 'required|integer|min:0', + ]); + $evento->update(['limite_qr_jugador' => $data['limite_qr_jugador']]); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Pase de QRs para el evento actualizado.'); + } + + // Si es edición, restringimos los campos permitidos según solicitud del usuario + if ($id) { + $data = $request->validate([ + 'fecha_evento' => 'required|date', + 'hora_inicio' => 'required', + 'hora_fin' => 'required', + 'limite_qr_jugador' => 'nullable|integer|min:0', + 'marcador_local' => 'nullable|integer|min:0', + 'marcador_visitante' => 'nullable|integer|min:0', + 'nombre_evento' => 'nullable|string|max:200', + ]); + + // Conservar valores que no deberían cambiar para que la validación posterior (deporte) no falle + $data['id_torneo'] = $evento->id_torneo; + $data['id_equipo_local'] = $evento->id_equipo_local; + $data['id_equipo_visitante'] = $evento->id_equipo_visitante; + + // Si el nombre viene vacío en edición, también lo autogeneramos? + // El usuario pidió que se setee automáticamente al no poner nada. + if (empty($data['nombre_evento'])) { + $local = Equipo::findOrFail($data['id_equipo_local']); + $visit = Equipo::findOrFail($data['id_equipo_visitante']); + $grupoName = 'General'; + if (!empty($data['id_torneo'])) { + $rel = DB::table('torneo_equipo') + ->where('id_torneo', $data['id_torneo']) + ->where('id_equipo', $data['id_equipo_local']) + ->first(); + if ($rel) $grupoName = $rel->grupo ?? 'General'; + } + $data['nombre_evento'] = ($local->club->nombre ?? 'Local') . ' vs ' . ($visit->club->nombre ?? 'Visitante') . " ({$grupoName})"; + } + + $data['sede'] = $evento->sede; + $data['precio'] = $evento->precio; + } else { + $data = $request->validate([ + 'nombre_evento' => 'nullable|string|max:200', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + 'fecha_evento' => 'required|date', + 'hora_inicio' => 'required', + 'hora_fin' => 'required', + 'sede' => 'nullable|string|max:200', + 'id_equipo_local' => 'nullable|integer|exists:equipos,id_equipo', + 'id_equipo_visitante' => 'nullable|integer|exists:equipos,id_equipo', + 'precio' => 'nullable|numeric|min:0', + 'limite_qr_jugador' => 'nullable|integer|min:0', + 'marcador_local' => 'nullable|integer|min:0', + 'marcador_visitante' => 'nullable|integer|min:0', + ]); + } + + // Validaciones Deportivas + if ($data['id_equipo_local'] && $data['id_equipo_visitante']) { + $local = Equipo::findOrFail($data['id_equipo_local']); + $visit = Equipo::findOrFail($data['id_equipo_visitante']); + + if ($local->categoria != $visit->categoria) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer a la misma categoría.']); + } + + if (!empty($data['id_torneo'])) { + $torneo = \App\Models\Torneo::findOrFail($data['id_torneo']); + $localInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_local'])->first(); + $visitInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_visitante'])->first(); + + if (!$localInTorneo || !$visitInTorneo) { + return back()->withInput()->withErrors(['id_torneo' => 'Uno o ambos equipos no están inscritos en este torneo.']); + } + + if ($localInTorneo->pivot->grupo != $visitInTorneo->pivot->grupo) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer al mismo grupo dentro del torneo.']); + } + } + + // Autogenerar Nombre si es nulo + if (empty($data['nombre_evento'])) { + $data['nombre_evento'] = ($local->club->nombre ?? 'Local') . ' vs ' . ($visit->club->nombre ?? 'Visitante'); + } + } + + $evento->update($data); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Evento actualizado correctamente.'); + } + + public function eventosDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $evento = Evento::findOrFail($id); + + // Al eliminar un evento, también eliminamos sus QRs para que no queden "huérfanos" + $evento->qrCodes()->delete(); + + $evento->delete(); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Evento eliminado correctamente.'); + } + + // ══════════════════════════════════ + // PROMOCIONES / LUGARES + // ══════════════════════════════════ + public function promocionesIndex(Request $request) + { + $this->checkSuperAdmin($request); + $promociones = Promocion::withCount('promoQrs')->orderBy('id', 'desc')->get(); + return view('admin.promociones.index', compact('promociones')); + } + + public function promocionesCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.promociones.form', ['promocion' => null]); + } + + public function promocionesStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'direccion' => 'required|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen_file' => 'nullable|image|max:2048', + ]); + + if ($request->hasFile('imagen_file')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'promos'); + $data['imagen'] = $path; + } + unset($data['imagen_file']); + + Promocion::create($data); + return redirect()->route('admin.promociones.index')->with('admin_msg', 'Promoción creada correctamente.'); + } + + public function promocionesEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $promocion = Promocion::findOrFail($id); + return view('admin.promociones.form', compact('promocion')); + } + + public function promocionesUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $promocion = Promocion::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'direccion' => 'required|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen_file' => 'nullable|image|max:2048', + ]); + + if ($request->hasFile('imagen_file')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'promos'); + $data['imagen'] = $path; + } + unset($data['imagen_file']); + + $promocion->update($data); + return redirect()->route('admin.promociones.index')->with('admin_msg', 'Promoción actualizada correctamente.'); + } + + public function promocionesDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $promocion = Promocion::findOrFail($id); + $promocion->delete(); + return redirect()->route('admin.promociones.index')->with('admin_msg', 'Promoción eliminada correctamente.'); + } + + // ══════════════════════════════════ + // NOTICIAS + // ══════════════════════════════════ + public function noticiasIndex(Request $request) + { + $this->checkSuperAdmin($request); + $noticias = Noticia::orderBy('fecha', 'desc')->orderBy('id', 'desc')->get(); + return view('admin.noticias.index', compact('noticias')); + } + + public function noticiasCreate(Request $request) + { + $this->checkSuperAdmin($request); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + return view('admin.noticias.form', ['noticia' => null, 'torneos' => $torneos]); + } + + public function noticiasStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'titulo' => 'required|string|max:200', + 'contenido' => 'required', + 'imagen_file' => 'nullable|image|max:5120', + 'categoria' => 'nullable|string|max:50', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + ]); + + if ($request->hasFile('imagen_file')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'noticias'); + $data['imagen'] = 'storage/' . $path; + } + unset($data['imagen_file']); + + $data['fecha'] = now(); + Noticia::create($data); + return redirect()->route('admin.noticias.index')->with('admin_msg', 'Noticia creada correctamente.'); + } + + public function noticiasEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $noticia = Noticia::findOrFail($id); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + return view('admin.noticias.form', compact('noticia', 'torneos')); + } + + public function noticiasUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $noticia = Noticia::findOrFail($id); + $data = $request->validate([ + 'titulo' => 'required|string|max:200', + 'contenido' => 'required', + 'imagen_file' => 'nullable|image|max:5120', + 'categoria' => 'nullable|string|max:50', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + ]); + + if ($request->hasFile('imagen_file')) { + // Eliminar imagen anterior si existe + if ($noticia->imagen && !str_starts_with($noticia->imagen, 'http') && file_exists(public_path($noticia->imagen))) { + @unlink(public_path($noticia->imagen)); + } + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'noticias'); + $data['imagen'] = 'storage/' . $path; + } + unset($data['imagen_file']); + + $noticia->update($data); + return redirect()->route('admin.noticias.index')->with('admin_msg', 'Noticia actualizada correctamente.'); + } + + public function noticiasDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $noticia = Noticia::findOrFail($id); + $noticia->delete(); + return redirect()->route('admin.noticias.index')->with('admin_msg', 'Noticia eliminada correctamente.'); + } + + // ══════════════════════════════════ + // ESCANEAR / VALIDAR QR + // ══════════════════════════════════ + public function validarQr(Request $request) + { + $this->checkGeneralAdmin($request); + $id_qr = $request->input('id_qr'); + $id_evento = $request->input('id_evento'); + + if (!$id_qr) { + return response()->json(['valid' => false, 'message' => 'QR inválido.']); + } + + $qr = QrCode::with('evento', 'jugador', 'aficionado')->where('id_qr', $id_qr)->first(); + + if (!$qr) { + return response()->json(['valid' => false, 'message' => 'QR no encontrado.']); + } + + // Si se seleccionó un evento, exigir que coincida + if ($id_evento && (string)$qr->id_evento !== (string)$id_evento) { + return response()->json(['valid' => false, 'message' => 'Este QR corresponde a otro evento.']); + } + + // Verificar vigencia del evento + if ($qr->evento) { + $ahora = \Carbon\Carbon::now(); + + $f = ($qr->evento->fecha_evento instanceof \Carbon\Carbon) ? $qr->evento->fecha_evento->format('Y-m-d') : substr($qr->evento->fecha_evento, 0, 10); + $h1 = ($qr->evento->hora_inicio instanceof \Carbon\Carbon) ? $qr->evento->hora_inicio->format('H:i:s') : $qr->evento->hora_inicio; + $h2 = ($qr->evento->hora_fin instanceof \Carbon\Carbon) ? $qr->evento->hora_fin->format('H:i:s') : $qr->evento->hora_fin; + + $inicio = \Carbon\Carbon::parse("$f $h1"); + $fin = \Carbon\Carbon::parse("$f $h2"); + + if ($fin->lessThanOrEqualTo($inicio)) { + $fin->addDay(); + } + + if ($ahora < $inicio) { + return response()->json(['valid' => false, 'message' => '⏳ El evento todavía no comenzó.']); + } + if ($ahora > $fin) { + return response()->json(['valid' => false, 'message' => 'Evento finalizado, QR inválido.']); + } + } + + // Verificar escaneos restantes + if ((int)$qr->escaneos_restantes <= 0) { + return response()->json(['valid' => false, 'message' => '❌ QR ya utilizado.']); + } + + // Decrementar escaneos (concurrency-safe via DB) + $affected = \Illuminate\Support\Facades\DB::table('qr_codes') + ->where('id_qr', $id_qr) + ->where('escaneos_restantes', '>', 0) + ->when($id_evento, function ($query) use ($id_evento) { + return $query->where('id_evento', $id_evento); + }) + ->decrement('escaneos_restantes'); + + if ($affected === 0) { + return response()->json(['valid' => false, 'message' => '❌ QR ya utilizado.']); + } + + // Info del titular según tipo de QR + $titular = ''; + $categoriaNombre = ''; + + if ($qr->jugador) { + $categoriaNombre = $qr->jugador->categoria_calculada; + } + + if ($qr->tipo_qr === 'invitado' && $qr->jugador) { + $titular = 'Jugador: ' . $qr->jugador->nombre . ' ' . $qr->jugador->apellido . ' (' . $categoriaNombre . ')'; + } elseif ($qr->tipo_qr === 'publico') { + if ($qr->aficionado) { + $titular = 'Aficionado: ' . $qr->aficionado->nombre . ' ' . $qr->aficionado->apellido; + } else { + $titular = 'QR de entrada pública (sin referencia)'; + } + } else { + $titular = $qr->jugador + ? $qr->jugador->nombre . ' ' . $qr->jugador->apellido . ' (' . $categoriaNombre . ')' + : ($qr->aficionado ? $qr->aficionado->nombre . ' ' . $qr->aficionado->apellido : 'Desconocido'); + } + + $mensajeValido = '✅ Acceso válido — Jugador del evento'; + + if ($qr->tipo_qr === 'libre_50') { + $mensajeValido = '✅ Tenés descuento en la entrada, 50% (Jugador Categoría Libre)'; + } + + return response()->json([ + 'valid' => true, + 'message' => $mensajeValido, + 'data' => [ + 'evento' => $qr->evento ? $qr->evento->nombre_evento : $qr->id_evento, + 'titular' => $titular, + 'categoria' => $categoriaNombre, + 'tipo' => $qr->tipo_qr, + 'restantes' => ($qr->escaneos_restantes > 0 ? $qr->escaneos_restantes - 1 : 0), + ], + ]); + } + + // ══════════════════════════════════ + // CONFIGURACIÓN GENERAL + // ══════════════════════════════════ + public function settingsIndex(Request $request) + { + $this->checkSuperAdmin($request); + $diasExpiracion = \App\Models\Configuracion::get('dias_expiracion_eventos', 30); + $backupFreq = \App\Models\Configuracion::get('backup_frequency', 'daily'); + $emailReportes = \App\Models\Configuracion::get('email_reportes', 'asociados@onapb.com'); + $lastRun = \App\Models\Configuracion::get('last_scheduler_run', 'Nunca detectado'); + + return view('admin.settings', compact('diasExpiracion', 'backupFreq', 'lastRun', 'emailReportes')); + } + + public function settingsUpdate(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'dias_expiracion_eventos' => 'required|integer|min:1', + 'backup_frequency' => 'required|string|in:daily,weekly,monthly', + 'email_reportes' => 'required|email', + ]); + + \App\Models\Configuracion::set('dias_expiracion_eventos', $data['dias_expiracion_eventos'], 'Días de antigüedad para borrar eventos y QRs'); + \App\Models\Configuracion::set('backup_frequency', $data['backup_frequency'], 'Frecuencia de backups automáticos (daily, weekly, monthly)'); + \App\Models\Configuracion::set('email_reportes', $data['email_reportes'], 'Email principal para recibir reportes del sistema'); + + return back()->with('admin_msg', 'Configuración actualizada correctamente.'); + } + + public function runManualTask(Request $request) + { + $this->checkSuperAdmin($request); + $command = $request->input('command'); + + try { + switch ($command) { + case 'cleanup': + \Illuminate\Support\Facades\Artisan::call('app:cleanup-old-events'); + $msg = '✅ Tarea de limpieza ejecutada.'; + break; + case 'backup': + // Usamos backup:run para forzar un backup completo + \Illuminate\Support\Facades\Artisan::call('backup:run'); + $msg = '✅ Proceso de backup iniciado.'; + break; + case 'report': + \Illuminate\Support\Facades\Artisan::call('reportes:semanal'); + $msg = '✅ Informe semanal enviado.'; + break; + default: + throw new \Exception('Comando no reconocido.'); + } + + return back()->with('admin_msg', $msg . ' (Salida: ' . \Illuminate\Support\Facades\Artisan::output() . ')'); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error("Error ejecutando comando manual: " . $e->getMessage()); + return back()->with('admin_error', 'Error al ejecutar tarea: ' . $e->getMessage()); + } + } + + // ══════════════════════════════════ + // SPONSORS + // ══════════════════════════════════ + public function sponsorsIndex(Request $request) + { + $this->checkSuperAdmin($request); + $sponsors = Sponsor::orderBy('orden')->latest()->get(); + return view('admin.sponsors.index', compact('sponsors')); + } + + public function sponsorsCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.sponsors.form'); + } + + public function sponsorsStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'imagen' => 'required|image|max:5120', + 'url' => 'nullable|url|max:255', + 'activo' => 'nullable|boolean', + 'orden' => 'nullable|integer', + ]); + + if ($request->hasFile('imagen')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'sponsors'); + $data['imagen'] = 'storage/' . $path; + } + + $data['activo'] = $request->has('activo'); + $data['orden'] = $data['orden'] ?? 0; + + Sponsor::create($data); + + return redirect()->route('admin.sponsors.index')->with('admin_msg', 'Sponsor creado correctamente.'); + } + + public function sponsorsEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $sponsor = Sponsor::findOrFail($id); + return view('admin.sponsors.form', compact('sponsor')); + } + + public function sponsorsUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $sponsor = Sponsor::findOrFail($id); + + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'imagen' => 'nullable|image|max:5120', + 'url' => 'nullable|url|max:255', + 'activo' => 'nullable|boolean', + 'orden' => 'nullable|integer', + ]); + + if ($request->hasFile('imagen')) { + // Eliminar imagen anterior si existe + if ($sponsor->imagen && file_exists(public_path($sponsor->imagen))) { + @unlink(public_path($sponsor->imagen)); + } + + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'sponsors'); + $data['imagen'] = 'storage/' . $path; + } + + $data['activo'] = $request->has('activo'); + $data['orden'] = $data['orden'] ?? 0; + + $sponsor->update($data); + + return redirect()->route('admin.sponsors.index')->with('admin_msg', 'Sponsor actualizado correctamente.'); + } + + public function sponsorsDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $sponsor = Sponsor::findOrFail($id); + + if ($sponsor->imagen && file_exists(public_path($sponsor->imagen))) { + @unlink(public_path($sponsor->imagen)); + } + + $sponsor->delete(); + + return redirect()->route('admin.sponsors.index')->with('admin_msg', 'Sponsor eliminado correctamente.'); + } + + // ══════════════════════════════════ + // TORNEOS + // ══════════════════════════════════ + public function torneosIndex(Request $request) + { + $this->checkSuperAdmin($request); + $torneos = \App\Models\Torneo::withCount('equipos')->latest()->get(); + return view('admin.torneos.index', compact('torneos')); + } + + public function torneosCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.torneos.form', ['torneo' => null]); + } + + public function torneosStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'fecha_inicio' => 'nullable|date', + 'fecha_fin' => 'nullable|date', + ]); + \App\Models\Torneo::create($data); + return redirect()->route('admin.torneos.index')->with('admin_msg', 'Torneo creado correctamente.'); + } + + public function torneosEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::with('equipos.club')->findOrFail($id); + $clubes = Club::with('equipos')->orderBy('nombre')->get(); + return view('admin.torneos.form', compact('torneo', 'clubes')); + } + + public function torneosUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'fecha_inicio' => 'nullable|date', + 'fecha_fin' => 'nullable|date', + ]); + $torneo->update($data); + return redirect()->route('admin.torneos.index')->with('admin_msg', 'Torneo actualizado correctamente.'); + } + + public function torneosDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::findOrFail($id); + $torneo->delete(); + return redirect()->route('admin.torneos.index')->with('admin_msg', 'Torneo eliminado correctamente.'); + } + + public function torneoAddEquipo(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::findOrFail($id); + $data = $request->validate([ + 'id_equipo' => 'required|integer|exists:equipos,id_equipo', + 'grupo' => 'nullable|string|max:50', + ]); + + if ($torneo->equipos()->where('torneo_equipo.id_equipo', $data['id_equipo'])->exists()) { + return back()->with('admin_error', 'El equipo ya está asignado a este torneo.'); + } + + $torneo->equipos()->attach($data['id_equipo'], ['grupo' => $data['grupo']]); + return back()->with('admin_msg', 'Equipo asignado al torneo correctamente.'); + } + + public function torneoRemoveEquipo($id, $id_equipo) + { + $this->checkSuperAdmin(request()); + $torneo = \App\Models\Torneo::findOrFail($id); + $torneo->equipos()->detach($id_equipo); + return back()->with('admin_msg', 'Equipo removido del torneo.'); + } + + public function eventosStats($id) + { + $this->checkGeneralAdmin(request()); + $evento = Evento::with(['equipoLocal.jugadores', 'equipoVisitante.jugadores'])->findOrFail($id); + + // Restricción para Administradores de Club: Solo partidos donde participa su club + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + if ($evento->equipoLocal->id_club != $idClub && $evento->equipoVisitante->id_club != $idClub) { + abort(403, 'No tienes permiso para gestionar estadísticas de este partido.'); + } + } + + $stats = \App\Models\EventoJugador::where('id_evento', $id)->get()->keyBy('id_jugador'); + return view('admin.eventos.stats', compact('evento', 'stats')); + } + + public function eventosStatsStore(Request $request, $id) + { + $this->checkGeneralAdmin($request); + + // Validación de seguridad para Edición + if (session('admin_role') == 2) { + $evento = Evento::findOrFail($id); + $idClub = session('admin_id_club'); + if ($evento->equipoLocal->id_club != $idClub && $evento->equipoVisitante->id_club != $idClub) { + abort(403, 'No tienes permiso para editar estadísticas de este partido.'); + } + } + + $request->validate([ + 'stats' => 'required|array', + 'stats.*.puntos' => 'required|integer|min:0', + 'stats.*.faltas' => 'required|integer|min:0|max:5', + ]); + + foreach ($request->stats as $id_jugador => $vals) { + // Si es Admin de Club, solo puede guardar sus propios jugadores + if (session('admin_role') == 2) { + $jugador = Jugador::find($id_jugador); + if (!$jugador || $jugador->id_club_actual != session('admin_id_club')) { + continue; + } + } + + \App\Models\EventoJugador::updateOrCreate( + ['id_evento' => $id, 'id_jugador' => $id_jugador], + ['puntos' => $vals['puntos'], 'faltas' => $vals['faltas']] + ); + } + + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Estadísticas guardadas correctamente.'); + } +} diff --git a/app/Http/Controllers/AdminUserController.php b/app/Http/Controllers/AdminUserController.php new file mode 100644 index 0000000..8cf1ca3 --- /dev/null +++ b/app/Http/Controllers/AdminUserController.php @@ -0,0 +1,55 @@ +json($users); + } + + public function show($id) + { + $user = AdminUser::findOrFail($id); + return response()->json($user); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'username' => 'required|string|max:50|unique:admin_users', + 'password' => 'required|string', + 'role' => 'nullable|integer', + ]); + $data['password'] = bcrypt($data['password']); + $user = AdminUser::create($data); + return response()->json($user, 201); + } + + public function update(Request $request, $id) + { + $user = AdminUser::findOrFail($id); + $data = $request->validate([ + 'username' => 'sometimes|string|max:50|unique:admin_users,username,' . $id, + 'password' => 'sometimes|string', + 'role' => 'sometimes|integer', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $user->update($data); + return response()->json($user); + } + + public function destroy($id) + { + $user = AdminUser::findOrFail($id); + $user->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/AficionadoController.php b/app/Http/Controllers/AficionadoController.php new file mode 100644 index 0000000..f3eadd7 --- /dev/null +++ b/app/Http/Controllers/AficionadoController.php @@ -0,0 +1,67 @@ +json($aficionados); + } + + public function show($id) + { + $aficionado = Aficionado::findOrFail($id); + return response()->json($aficionado); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'dni' => 'required|string|max:20|unique:aficionados', + 'fecha_nacimiento' => 'nullable|date', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'localidad' => 'nullable|string|max:100', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $aficionado = Aficionado::create($data); + return response()->json($aficionado, 201); + } + + public function update(Request $request, $id) + { + $aficionado = Aficionado::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'sometimes|string|max:100', + 'apellido' => 'sometimes|string|max:100', + 'dni' => 'sometimes|string|max:20|unique:aficionados,dni,' . $id . ',id_aficionado', + 'fecha_nacimiento' => 'nullable|date', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'localidad' => 'nullable|string|max:100', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $aficionado->update($data); + return response()->json($aficionado); + } + + public function destroy($id) + { + $aficionado = Aficionado::findOrFail($id); + $aficionado->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..a801d3a --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,411 @@ +post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ + 'secret' => config('services.turnstile.secret_key'), + 'response' => $token, + 'remoteip' => request()->ip(), + ]); + + return $response->successful() && $response->json('success'); + } + + public function login(Request $request) + { + $tipo = $request->input('tipo'); + + if ($tipo === 'admin') { + return $this->loginAdmin($request); + } + + return $this->loginPlayer($request); + } + + public function loginPlayer(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('login_error', 'Error de verificación de seguridad (Turnstile).')->with('login_tab', 'player'); + } + + $dni = $request->input('dni'); + $password = $request->input('password'); + + $jugador = Jugador::where('documento', $dni)->where('activo', true)->first(); + + if ($jugador && $jugador->password && Hash::check($password, $jugador->password)) { + $request->session()->put('user_logged_in', true); + $request->session()->put('user_tipo', 'jugador'); + $request->session()->put('user_id', $jugador->id_jugador); + $request->session()->put('user_name', $jugador->nombre . ' ' . $jugador->apellido); + $request->session()->put('user_documento', $jugador->documento); + $request->session()->put('user_ultimo_acceso', time()); + + return redirect()->intended('/'); + } + + $aficionado = Aficionado::where('dni', $dni)->first(); + + if ($aficionado && $aficionado->password && Hash::check($password, $aficionado->password)) { + $request->session()->put('user_logged_in', true); + $request->session()->put('user_tipo', 'aficionado'); + $request->session()->put('user_id', $aficionado->id_aficionado); + $request->session()->put('user_name', $aficionado->nombre . ' ' . $aficionado->apellido); + $request->session()->put('user_documento', $aficionado->dni); + $request->session()->put('user_ultimo_acceso', time()); + + return redirect()->intended('/'); + } + + return back()->with('login_error', 'DNI o contraseña incorrectos')->with('login_tab', 'player'); + } + + public function loginAdmin(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('login_error', 'Error de verificación de seguridad (Turnstile).')->with('login_tab', 'admin'); + } + + $username = $request->input('username'); + $password = $request->input('password'); + + $admin = AdminUser::whereRaw('BINARY `username` = ?', [$username])->first(); + + if ($admin && Hash::check($password, $admin->password)) { + $request->session()->put('admin_logged_in', true); + $request->session()->put('admin_id', $admin->id); + $request->session()->put('admin_username', $admin->username); + $request->session()->put('admin_role', $admin->role); + $request->session()->put('admin_id_club', $admin->id_club); + + if ($admin->id_club && $admin->club) { + $request->session()->put('admin_club_nombre', $admin->club->nombre); + } + + $request->session()->put('ultimo_acceso', time()); + + return redirect()->intended('/'); + } + + return back()->with('login_error', 'Usuario o contraseña incorrectos')->with('login_tab', 'admin'); + } + + public function logout(Request $request) + { + $isAdmin = $request->session()->get('admin_logged_in'); + + if ($isAdmin) { + $request->session()->forget(['admin_logged_in', 'admin_id', 'admin_username', 'admin_role', 'ultimo_acceso']); + $msg = 'Sesión de administrador cerrada correctamente.'; + } else { + $request->session()->forget(['user_logged_in', 'user_tipo', 'user_id', 'user_name', 'user_documento', 'user_ultimo_acceso']); + $msg = 'Sesión cerrada correctamente.'; + } + + return redirect('/?logout_msg=' . urlencode($msg)); + } + + public function showLoginForm() + { + return view('welcome'); + } + + public function recuperar(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('mensaje', '⚠️ Error de verificación de seguridad (Captcha).'); + } + + $dni = trim($request->input('dni')); + $email = trim($request->input('email')); + + if (empty($dni) || empty($email)) { + return back()->with('mensaje', 'Debes ingresar tu DNI y correo electrónico.'); + } + + $jugador = Jugador::where('documento', $dni)->where('email', $email)->first(); + $aficionado = Aficionado::where('dni', $dni)->where('email', $email)->first(); + + $usuario = $jugador ?: $aficionado; + + if (!$usuario) { + return back()->with('mensaje', 'No se encontró un usuario con ese DNI y correo.'); + } + + $token = bin2hex(random_bytes(16)); + $expires = now()->addHour(); + + if ($jugador) { + $jugador->update([ + 'reset_token' => $token, + 'reset_expira' => $expires + ]); + } else { + $aficionado->update([ + 'reset_token' => $token, + 'reset_expira' => $expires + ]); + } + try { + Mail::to($usuario->email)->send(new ResetPasswordMail($usuario, $token)); + } catch (\Exception $e) { + Log::error("Error enviando mail de recuperación: " . $e->getMessage()); + } + + return back()->with('mensaje', '📩 Te enviamos un correo con las instrucciones para recuperar tu contraseña.'); + } + + public function resetPasswordForm($token) + { + // Verificar que el token exista y no esté expirado + $jugador = Jugador::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + $aficionado = Aficionado::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + + if (!$jugador && !$aficionado) { + return redirect()->route('recuperar')->with('mensaje', '❌ El enlace es inválido o ya expiró. Solicitá uno nuevo.'); + } + + return view('auth.reset_password', compact('token')); + } + + public function resetPassword(Request $request) + { + $request->validate([ + 'token' => 'required|string', + 'password' => 'required|confirmed|min:6', + ]); + + $token = $request->input('token'); + + $jugador = Jugador::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + $aficionado = Aficionado::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + + $usuario = $jugador ?: $aficionado; + + if (!$usuario) { + return redirect()->route('recuperar')->with('mensaje', '❌ El enlace es inválido o ya expiró. Solicitá uno nuevo.'); + } + + $usuario->update([ + 'password' => bcrypt($request->input('password')), + 'reset_token' => null, + 'reset_expira' => null, + ]); + + return redirect('/')->with('login_success', '✅ Contraseña cambiada correctamente. Ya podés iniciar sesión.'); + } + + public function registroAficionado(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('registro_msg', '⚠️ Error de verificación de seguridad (Captcha).')->withInput(); + } + + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'dni' => 'required|string|unique:aficionados,dni', + 'email' => 'required|email|unique:aficionados,email', + 'fecha_nacimiento' => 'nullable|date', + 'telefono' => 'nullable|string|max:50', + 'localidad' => 'nullable|string|max:100', + 'password' => 'required|confirmed|min:6', + ]); + + $data['password'] = bcrypt($data['password']); + $data['fecha_registro'] = now(); + + $aficionado = Aficionado::create($data); + + try { + Mail::to($aficionado->email)->send(new WelcomeMail($aficionado, 'aficionado')); + } catch (\Exception $e) { + Log::error("Error enviando mail de bienvenida a Aficionado: " . $e->getMessage()); + } + + return redirect()->route('asociate')->with('mensaje', '✅ Te registraste correctamente. Ya podés iniciar sesión.'); + } + + public function buscarJugador(Request $request) + { + $request->validate([ + 'nombre' => 'required|string', + 'apellido' => 'required|string', + 'dni' => 'required|string', + 'acepto' => 'required', + ]); + + $dni = preg_replace('/[^0-9]/', '', $request->input('dni')); + $nombre = strtoupper(trim($request->input('nombre'))); + $apellido = strtoupper(trim($request->input('apellido'))); + + // Buscar jugador por DNI + $jugador = Jugador::where('documento', $dni)->first(); + + if (!$jugador) { + // Verificar si ya está registrado como aficionado + $aficionado = Aficionado::where('dni', $dni)->first(); + if ($aficionado) { + return redirect()->route('asociate')->with('mensaje', '⚠️ Ya estás registrado como aficionado.'); + } + // No existe - se debe registrar como aficionado + return redirect()->route('asociate')->with('mensaje', '⚠️ No encontramos tu registro como jugador. Podés registrarte como aficionado.'); + } + + // --- Lógica Smart Match --- + $nombreEnBD = $this->normalizeString($jugador->nombre ?? ''); + $apellidoEnBD = $this->normalizeString($jugador->apellido ?? ''); + $terminosBD = explode(' ', $nombreEnBD . ' ' . $apellidoEnBD); + + $palabrasNombreIn = explode(' ', $this->normalizeString($nombre)); + $palabrasApellidoIn = explode(' ', $this->normalizeString($apellido)); + + $nombreCoincide = false; + foreach ($palabrasNombreIn as $p) { + if ($this->isApproxMatch($p, $terminosBD)) { + $nombreCoincide = true; + break; + } + } + + $apellidoCoincide = false; + foreach ($palabrasApellidoIn as $p) { + if ($this->isApproxMatch($p, $terminosBD)) { + $apellidoCoincide = true; + break; + } + } + + if (!$nombreCoincide || !$apellidoCoincide) { + return redirect()->route('asociate')->with('mensaje', '⚠️ El DNI ingresado no coincide con el Nombre y Apellido proporcionados. Por favor, verifica tus datos.'); + } + // --- Fin Smart Match --- + + if ($jugador->activo) { + return redirect()->route('asociate')->with('registro_msg', 'Este jugador ya está registrado en el sistema.'); + } + + // Jugador encontrado e inactivo - mostrar formulario para completar + $club = null; + if ($jugador->id_club_actual) { + $clubObj = \App\Models\Club::find($jugador->id_club_actual); + $club = $clubObj ? $clubObj->nombre : null; + } + + $jugador_encontrado = [ + 'documento' => $jugador->documento, + 'nombre' => $jugador->nombre, + 'apellido' => $jugador->apellido, + 'fecha_nacimiento' => $jugador->fecha_nacimiento, + 'club' => $club, + 'categoria' => $jugador->categoria, + ]; + + return view('auth.asociate', compact('jugador_encontrado'))->with('tab', 'jugador'); + } + + public function completarRegistroJugador(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('registro_msg', '⚠️ Error de verificación de seguridad (Captcha).')->withInput(); + } + + $request->validate([ + 'dni' => 'required|string', + 'email' => 'required|email', + 'telefono' => 'nullable|string', + 'password' => 'required|confirmed|min:6', + ]); + + $dni = preg_replace('/[^0-9]/', '', $request->input('dni')); + + $jugador = Jugador::where('documento', $dni)->first(); + + if (!$jugador) { + return redirect()->route('asociate')->with('registro_msg', 'Jugador no encontrado.'); + } + + if ($jugador->activo) { + return redirect()->route('asociate')->with('registro_msg', 'Este jugador ya está registrado.'); + } + + $jugador->update([ + 'email' => $request->input('email'), + 'telefono' => $request->input('telefono'), + 'password' => bcrypt($request->input('password')), + 'activo' => 1, + 'fecha_registro' => now(), + ]); + + try { + Mail::to($jugador->email)->send(new WelcomeMail($jugador, 'jugador')); + } catch (\Exception $e) { + Log::error("Error enviando mail de bienvenida a Jugador: " . $e->getMessage()); + } + + return redirect()->route('asociate')->with('mensaje', '✅ Registro completado exitosamente. Ya podés iniciar sesión.'); + } + + /** + * Normaliza un string para comparaciones (mayúsculas, sin acentos, sin espacios extras) + */ + private function normalizeString($str) + { + $unwanted_array = ['Š'=>'S', 'š'=>'s', 'Ž'=>'Z', 'ž'=>'z', 'À'=>'A', 'Á'=>'A', 'Â'=>'A', 'Ã'=>'A', 'Ä'=>'A', 'Å'=>'A', 'Æ'=>'A', 'Ç'=>'C', 'È'=>'E', 'É'=>'E', + 'Ê'=>'E', 'Ë'=>'E', 'Ì'=>'I', 'Í'=>'I', 'Î'=>'I', 'Ï'=>'I', 'Ñ'=>'N', 'Ò'=>'O', 'Ó'=>'O', 'Ô'=>'O', 'Õ'=>'O', 'Ö'=>'O', 'Ø'=>'O', 'Ù'=>'U', + 'Ú'=>'U', 'Û'=>'U', 'Ü'=>'U', 'Ý'=>'Y', 'Þ'=>'B', 'ß'=>'Ss', 'à'=>'a', 'á'=>'a', 'â'=>'a', 'ã'=>'a', 'ä'=>'a', 'å'=>'a', 'æ'=>'a', 'ç'=>'c', + 'è'=>'e', 'é'=>'e', 'ê'=>'e', 'ë'=>'e', 'ì'=>'i', 'í'=>'i', 'î'=>'i', 'ï'=>'i', 'ð'=>'o', 'ñ'=>'n', 'ò'=>'o', 'ó'=>'o', 'ô'=>'o', 'õ'=>'o', + 'ö'=>'o', 'ø'=>'o', 'ù'=>'u', 'ú'=>'u', 'û'=>'u', 'ý'=>'y', 'þ'=>'b', 'ÿ'=>'y']; + + $str = strtr($str, $unwanted_array); + return strtoupper(trim(preg_replace('/\s+/', ' ', $str))); + } + + /** + * Comprueba si una palabra coincide aproximadamente con alguna de la lista + */ + private function isApproxMatch($word, $list) + { + if (strlen($word) < 3) return false; + + foreach ($list as $item) { + if (strlen($item) < 3) continue; + + // Coincidencia exacta o contenida + if ($word === $item || strpos($item, $word) !== false || strpos($word, $item) !== false) { + return true; + } + + // Levenshtein para errores de tipeo (máximo 1 de distancia) + if (levenshtein($word, $item) <= 1) { + return true; + } + } + return false; + } +} diff --git a/app/Http/Controllers/ClubController.php b/app/Http/Controllers/ClubController.php new file mode 100644 index 0000000..9ad2eb8 --- /dev/null +++ b/app/Http/Controllers/ClubController.php @@ -0,0 +1,47 @@ +get(); + return response()->json($clubes); + } + + public function show($id) + { + $club = Club::with('equipos', 'jugadores')->findOrFail($id); + return response()->json($club); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + ]); + $club = Club::create($data); + return response()->json($club, 201); + } + + public function update(Request $request, $id) + { + $club = Club::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'sometimes|string|max:100', + ]); + $club->update($data); + return response()->json($club); + } + + public function destroy($id) + { + $club = Club::findOrFail($id); + $club->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +getUserRole(); + + // Segmentar contenido + $filteredMd = $this->segmentMarkdown($md, $role); + + $parsedown = new \Parsedown(); + $content = $parsedown->text($filteredMd); + } + + return view('documentacion.index', compact('content')); + } + + /** + * Detecta el rol del usuario basado en las variables de sesión + */ + private function getUserRole() + { + if (session()->get('admin_logged_in')) { + $adminRole = session()->get('admin_role'); + return ($adminRole == 1) ? 'superadmin' : 'admin_club'; + } + + if (session()->get('user_logged_in')) { + return session()->get('user_tipo'); // 'jugador' o 'aficionado' + } + + return 'visitante'; + } + + /** + * Segmenta y filtra el Markdown según el rol + */ + private function segmentMarkdown($md, $role) + { + $markers = [ + 'cap1' => '', + 'cap2' => '', + 'cap3' => '', + 'cap4' => '', + 'cap5' => '', + 'faq' => '## ❓ Preguntas Frecuentes' + ]; + + // Encontrar posiciones de los marcadores + $positions = []; + foreach ($markers as $key => $marker) { + $pos = strpos($md, $marker); + if ($pos !== false) { + $positions[$key] = $pos; + } + } + asort($positions); + + $keys = array_keys($positions); + $sections = []; + + // Intro (antes del primer capítulo) + $sections['intro'] = substr($md, 0, $positions[$keys[0]]); + + // Capítulos y FAQ + for ($i = 0; $i < count($keys); $i++) { + $start = $positions[$keys[$i]]; + $end = isset($keys[$i+1]) ? $positions[$keys[$i+1]] : strlen($md); + $sections[$keys[$i]] = substr($md, $start, $end - $start); + } + + // Determinar qué capítulos mostrar + $allowedChapters = [1]; // Todos ven el Cap 1 + $showSections = ['intro', 'cap1', 'faq']; + + switch ($role) { + case 'superadmin': + $allowedChapters = [1, 2, 3, 4, 5]; + $showSections = ['intro', 'cap1', 'cap2', 'cap3', 'cap4', 'cap5', 'faq']; + break; + case 'admin_club': + $allowedChapters = [1, 4]; + $showSections = ['intro', 'cap1', 'cap4', 'faq']; + break; + case 'jugador': + $allowedChapters = [1, 2]; + $showSections = ['intro', 'cap1', 'cap2', 'faq']; + break; + case 'aficionado': + $allowedChapters = [1, 3]; + $showSections = ['intro', 'cap1', 'cap3', 'faq']; + break; + default: // visitante + $allowedChapters = [1]; + $showSections = ['intro', 'cap1', 'faq']; + break; + } + + // Filtrar Tabla de Contenidos en la Intro + $sections['intro'] = $this->filterTOC($sections['intro'], $allowedChapters); + + // Unir secciones seleccionadas + $finalMd = ""; + foreach ($showSections as $s) { + if (isset($sections[$s])) { + $finalMd .= $sections[$s] . "\n\n---\n\n"; + } + } + + return $finalMd; + } + + /** + * Filtra la tabla de contenidos para mostrar solo los capítulos permitidos + */ + private function filterTOC($intro, $allowedChapters) + { + $lines = explode("\n", $intro); + $filteredLines = []; + $inTable = false; + + foreach ($lines as $line) { + if (strpos($line, '| Capítulo | Perfil |') !== false) { + $inTable = true; + $filteredLines[] = $line; + continue; + } + + if ($inTable) { + if (trim($line) === '' || (strpos($line, '|') === false && trim($line) !== '')) { + $inTable = false; + $filteredLines[] = $line; + continue; + } + + if (strpos($line, '|---|') !== false) { + $filteredLines[] = $line; + continue; + } + + // Filtrar fila de la tabla + $matched = false; + foreach ($allowedChapters as $cap) { + if (strpos($line, "[Capítulo $cap]") !== false) { + $matched = true; + break; + } + } + + if ($matched) { + $filteredLines[] = $line; + } + } else { + $filteredLines[] = $line; + } + } + return implode("\n", $filteredLines); + } + + public function download() + { + $path = base_path('misc/MANUAL_USUARIO.md'); + + if (!File::exists($path)) { + abort(404, 'No se pudo generar el manual porque el archivo base no existe.'); + } + + $md = File::get($path); + + // Detectar rol de la sesión + $role = $this->getUserRole(); + + // Segmentar contenido + $filteredMd = $this->segmentMarkdown($md, $role); + + // Convertir Markdown a HTML + $parsedown = new \Parsedown(); + $htmlContent = $parsedown->text($filteredMd); + + // Preparar Logo en Base64 para el PDF + $logoBase64 = null; + $logoPath = public_path('logo.png'); + if (File::exists($logoPath)) { + $type = pathinfo($logoPath, PATHINFO_EXTENSION); + $data = File::get($logoPath); + $logoBase64 = 'data:image/' . $type . ';base64,' . base64_encode($data); + } + + // Depuración temporal: Descomenta si querés ver qué rol detecta antes de generar el PDF + // dd('Rol detectado: ' . $role); + + // Generar PDF con dompdf + $pdf = Pdf::loadView('documentacion.pdf', [ + 'content' => $htmlContent, + 'logo' => $logoBase64 + ]); + + // Ajustar papel y orientación + $pdf->setPaper('A4', 'portrait'); + + // Nombre de archivo único para evitar caché de navegador + $filename = 'Manual_Segmentado_' . $role . '_' . time() . '.pdf'; + + return $pdf->download($filename); + } +} diff --git a/app/Http/Controllers/EquipoController.php b/app/Http/Controllers/EquipoController.php new file mode 100644 index 0000000..acbb688 --- /dev/null +++ b/app/Http/Controllers/EquipoController.php @@ -0,0 +1,57 @@ +get(); + return response()->json($equipos); + } + + public function show($id) + { + $equipo = Equipo::with('club', 'jugadores')->findOrFail($id); + return response()->json($equipo); + } + + public function publicShow($id) + { + $equipo = Equipo::with(['club', 'jugadores'])->findOrFail($id); + return view('equipos.show', compact('equipo')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_club' => 'required|integer|exists:clubes,id_club', + 'categoria' => 'required|string|max:20', + 'division' => 'nullable|string|max:5', + ]); + $equipo = Equipo::create($data); + return response()->json($equipo, 201); + } + + public function update(Request $request, $id) + { + $equipo = Equipo::findOrFail($id); + $data = $request->validate([ + 'id_club' => 'sometimes|integer|exists:clubes,id_club', + 'categoria' => 'sometimes|string|max:20', + 'division' => 'nullable|string|max:5', + ]); + $equipo->update($data); + return response()->json($equipo); + } + + public function destroy($id) + { + $equipo = Equipo::findOrFail($id); + $equipo->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/EventoController.php b/app/Http/Controllers/EventoController.php new file mode 100644 index 0000000..826cd21 --- /dev/null +++ b/app/Http/Controllers/EventoController.php @@ -0,0 +1,84 @@ +get('fecha', now()->toDateString()); + $fechaSeleccionada = Carbon::parse($fechaStr); + + // Generar rango de fechas (Ayer, Hoy, Mañana + otros) + $fechasNav = []; + for ($i = -3; $i <= 3; $i++) { + $d = now()->addDays($i); + $fechasNav[] = [ + 'label' => $this->getFechaLabel($d), + 'fecha' => $d->toDateString(), + 'active' => $d->isSameDay($fechaSeleccionada) + ]; + } + + $eventos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->whereDate('fecha_evento', $fechaSeleccionada->toDateString()) + ->orderBy('hora_inicio', 'asc') + ->get() + ->map(function ($e) { + $e->estado = $this->calcularEstado($e->fecha_evento, $e->hora_inicio, $e->hora_fin); + return $e; + }); + + return view('eventos.index', compact('eventos', 'fechasNav', 'fechaSeleccionada')); + } + + private function getFechaLabel(Carbon $date) + { + $fecha = $date->format('d/m'); + if ($date->isToday()) return "HOY $fecha"; + if ($date->isYesterday()) return "AYER $fecha"; + if ($date->isTomorrow()) return "MAÑANA $fecha"; + + return $date->translatedFormat('D d/m'); + } + + public function show($id) + { + $evento = Evento::with(['equipoLocal.club', 'equipoVisitante.club', 'qrCodes']) + ->findOrFail($id); + + $evento->estado = $this->calcularEstado($evento->fecha_evento, $evento->hora_inicio, $evento->hora_fin); + + $isAdmin = session()->has('admin_logged_in') && session('admin_logged_in'); + $isUser = session()->has('user_logged_in') && session('user_logged_in'); + + return view('eventos.show', compact('evento', 'isAdmin', 'isUser')); + } + + private function calcularEstado($fechaEvento, $horaInicio, $horaFin) + { + $tz = new \DateTimeZone(config('app.timezone', 'America/Argentina/Buenos_Aires')); + + // Asegurarnos de tener strings limpios (Y-m-d y H:i:s) + $f = ($fechaEvento instanceof \Carbon\Carbon) ? $fechaEvento->format('Y-m-d') : substr($fechaEvento, 0, 10); + $h1 = ($horaInicio instanceof \Carbon\Carbon) ? $horaInicio->format('H:i:s') : $horaInicio; + $h2 = ($horaFin instanceof \Carbon\Carbon) ? $horaFin->format('H:i:s') : $horaFin; + + $inicio = new \DateTime("$f $h1", $tz); + $fin = new \DateTime("$f $h2", $tz); + + if ($fin <= $inicio) { + $fin->modify('+1 day'); + } + + $ahora = new \DateTime('now', $tz); + + if ($ahora >= $inicio && $ahora <= $fin) return 'Activo'; + if ($ahora < $inicio) return 'Próximo'; + return 'Finalizado'; + } +} diff --git a/app/Http/Controllers/GeniusAgentController.php b/app/Http/Controllers/GeniusAgentController.php new file mode 100644 index 0000000..7de4886 --- /dev/null +++ b/app/Http/Controllers/GeniusAgentController.php @@ -0,0 +1,81 @@ +validate([ + 'message' => ['required', 'string', 'max:1000'], + 'thread_id' => ['nullable', 'string', 'uuid'], + ]); + + $message = $request->string('message')->trim()->toString(); + + if (session('admin_logged_in')) { + return $this->handleAdmin($message, $request->input('thread_id')); + } + + return $this->handlePublic($request, $message); + } + + private function handleAdmin(string $message, ?string $threadId): JsonResponse + { + $adminId = (int) session('admin_id'); + $isSuperadmin = (int) session('admin_role') === 1; + $thread = AgentThread::findOrCreateForAdmin($threadId, $adminId); + + $reply = $this->service->chatAdmin($message, $thread, $isSuperadmin); + + return response()->json([ + 'reply' => $reply, + 'thread_id' => $thread->thread_id, + ]); + } + + private function handlePublic(Request $request, string $message): JsonResponse + { + $maxMessages = (int) config('services.genius.max_messages_per_session', 20); + $windowMin = (int) config('services.genius.session_window_minutes', 60); + + $session = $request->session(); + $startedAt = $session->get('agent_window_started_at'); + $count = (int) $session->get('agent_window_count', 0); + + if ($startedAt === null || (time() - (int) $startedAt) > $windowMin * 60) { + $startedAt = time(); + $count = 0; + } + + if ($maxMessages > 0 && $count >= $maxMessages) { + $remaining = max(1, (int) ceil(($windowMin * 60 - (time() - (int) $startedAt)) / 60)); + return response()->json([ + 'reply' => "Llegaste al límite de {$maxMessages} consultas por sesión. " + . "Volvé a intentar en {$remaining} minuto(s) o contactá directamente a OnAPB.", + 'limit_reached' => true, + ]); + } + + $history = $session->get('agent_messages', []); + $reply = $this->service->chatPublic($message, $history); + + $history[] = ['role' => 'user', 'content' => $message]; + $history[] = ['role' => 'assistant', 'content' => $reply]; + + $session->put('agent_messages', $history); + $session->put('agent_window_started_at', $startedAt); + $session->put('agent_window_count', $count + 1); + + return response()->json(['reply' => $reply]); + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php new file mode 100644 index 0000000..e32bb4c --- /dev/null +++ b/app/Http/Controllers/HomeController.php @@ -0,0 +1,86 @@ +getEventos(); + $promociones = Promocion::whereNotNull('descripcion') + ->where('descripcion', '!=', '') + ->orderBy('id', 'desc') + ->get(); + + $carouselItems = CarouselItem::where('activo', true) + ->orderBy('orden', 'asc') + ->get(); + + $noticias = Noticia::orderBy('id', 'desc') + ->take(3) + ->get(); + + $torneos = Torneo::orderBy('id', 'desc') + ->get(); + + $tournamentService = new TournamentService(); + $standingsData = []; + foreach ($torneos as $t) { + $standingsData[$t->id] = $tournamentService->getStandings($t->id); + } + + return view('welcome', compact('eventos', 'promociones', 'carouselItems', 'noticias', 'torneos', 'standingsData')); + } + + private function getEventos() + { + $eventos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->orderBy('fecha_evento', 'asc') + ->orderBy('hora_inicio', 'asc') + ->get() + ->map(function ($e) { + $estado = $this->calcularEstado($e->fecha_evento, $e->hora_inicio, $e->hora_fin); + if (in_array($estado, ['Activo', 'Próximo'])) { + $e->estado = $estado; + return $e; + } + return null; + }) + ->filter() + ->values(); + + return $eventos; + } + + private function calcularEstado($fechaEvento, $horaInicio, $horaFin) + { + $tz = new \DateTimeZone(config('app.timezone', 'America/Argentina/Buenos_Aires')); + + // Asegurarnos de tener strings limpios (Y-m-d y H:i:s) + $f = ($fechaEvento instanceof \Carbon\Carbon) ? $fechaEvento->format('Y-m-d') : substr($fechaEvento, 0, 10); + $h1 = ($horaInicio instanceof \Carbon\Carbon) ? $horaInicio->format('H:i:s') : $horaInicio; + $h2 = ($horaFin instanceof \Carbon\Carbon) ? $horaFin->format('H:i:s') : $horaFin; + + $inicio = new \DateTime("$f $h1", $tz); + $fin = new \DateTime("$f $h2", $tz); + + if ($fin <= $inicio) { + $fin->modify('+1 day'); + } + + $ahora = new \DateTime('now', $tz); + + if ($ahora >= $inicio && $ahora <= $fin) return 'Activo'; + if ($ahora < $inicio) return 'Próximo'; + return 'Finalizado'; + } +} diff --git a/app/Http/Controllers/JugadorController.php b/app/Http/Controllers/JugadorController.php new file mode 100644 index 0000000..1867573 --- /dev/null +++ b/app/Http/Controllers/JugadorController.php @@ -0,0 +1,76 @@ +get(); + return response()->json($jugadores); + } + + public function show($id) + { + $jugador = Jugador::with('clubActual', 'clubOrigen', 'equipos')->findOrFail($id); + return response()->json($jugador); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_jugador' => 'nullable|string|max:6', + 'documento' => 'required|string|max:20|unique:jugadores', + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'fecha_nacimiento' => 'nullable|date', + 'edad' => 'nullable|integer', + 'categoria' => 'nullable|string|max:20', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'nullable|integer|exists:clubes,id_club', + 'activo' => 'nullable|boolean', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $jugador = Jugador::create($data); + return response()->json($jugador, 201); + } + + public function update(Request $request, $id) + { + $jugador = Jugador::findOrFail($id); + $data = $request->validate([ + 'documento' => 'sometimes|string|max:20|unique:jugadores,documento,' . $id . ',id_jugador', + 'nombre' => 'sometimes|string|max:100', + 'apellido' => 'sometimes|string|max:100', + 'fecha_nacimiento' => 'nullable|date', + 'edad' => 'nullable|integer', + 'categoria' => 'nullable|string|max:20', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'nullable|integer|exists:clubes,id_club', + 'activo' => 'nullable|boolean', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $jugador->update($data); + return response()->json($jugador); + } + + public function destroy($id) + { + $jugador = Jugador::findOrFail($id); + $jugador->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/JugadorEquipoController.php b/app/Http/Controllers/JugadorEquipoController.php new file mode 100644 index 0000000..60e9d3a --- /dev/null +++ b/app/Http/Controllers/JugadorEquipoController.php @@ -0,0 +1,73 @@ +get(); + return response()->json($relaciones); + } + + public function show($idJugador, $idEquipo) + { + $relacion = JugadorEquipo::where('id_jugador', $idJugador) + ->where('id_equipo', $idEquipo) + ->with('jugador', 'equipo') + ->firstOrFail(); + return response()->json($relacion); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_jugador' => 'required|string|max:6|exists:jugadores,id_jugador', + 'id_equipo' => 'required|integer|exists:equipos,id_equipo', + 'fecha_alta' => 'nullable|date', + ]); + $relacion = JugadorEquipo::create($data); + return response()->json($relacion, 201); + } + + public function update(Request $request, $idJugador, $idEquipo) + { + $relacion = JugadorEquipo::where('id_jugador', $idJugador) + ->where('id_equipo', $idEquipo) + ->firstOrFail(); + + $data = $request->validate([ + 'fecha_alta' => 'nullable|date', + ]); + $relacion->update($data); + return response()->json($relacion); + } + + public function destroy($idJugador, $idEquipo) + { + $relacion = JugadorEquipo::where('id_jugador', $idJugador) + ->where('id_equipo', $idEquipo) + ->firstOrFail(); + $relacion->delete(); + return response()->json(null, 204); + } + + public function porJugador($idJugador) + { + $relaciones = JugadorEquipo::where('id_jugador', $idJugador) + ->with('equipo') + ->get(); + return response()->json($relaciones); + } + + public function porEquipo($idEquipo) + { + $relaciones = JugadorEquipo::where('id_equipo', $idEquipo) + ->with('jugador') + ->get(); + return response()->json($relaciones); + } +} diff --git a/app/Http/Controllers/NoticiaController.php b/app/Http/Controllers/NoticiaController.php new file mode 100644 index 0000000..d2e017c --- /dev/null +++ b/app/Http/Controllers/NoticiaController.php @@ -0,0 +1,61 @@ +get(); + + if ($request->expectsJson()) { + return response()->json($noticias); + } + + return view('noticias.index', compact('noticias')); + } + + public function show($id) + { + $noticia = Noticia::findOrFail($id); + + if (request()->expectsJson()) { + return response()->json($noticia); + } + + return view('noticias.show', compact('noticia')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'titulo' => 'required|string|max:200', + 'contenido' => 'required', + 'imagen' => 'nullable|string|max:200', + ]); + $noticia = Noticia::create($data); + return response()->json($noticia, 201); + } + + public function update(Request $request, $id) + { + $noticia = Noticia::findOrFail($id); + $data = $request->validate([ + 'titulo' => 'sometimes|string|max:200', + 'contenido' => 'sometimes', + 'imagen' => 'nullable|string|max:200', + ]); + $noticia->update($data); + return response()->json($noticia); + } + + public function destroy($id) + { + $noticia = Noticia::findOrFail($id); + $noticia->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/NotificacionController.php b/app/Http/Controllers/NotificacionController.php new file mode 100644 index 0000000..eae56a9 --- /dev/null +++ b/app/Http/Controllers/NotificacionController.php @@ -0,0 +1,143 @@ +service = $service; + } + + // ── Helper: usuario logueado ── + private function getUserSession(): ?array + { + if (!session()->has('user_logged_in')) return null; + return [ + 'tipo' => session('user_tipo'), + 'id' => session('user_id'), + ]; + } + + /** + * GET /notificaciones — Listado completo paginado + */ + public function index() + { + $u = $this->getUserSession(); + if (!$u) return redirect('/')->with('panel_error', 'Debés iniciar sesión.'); + + $notificaciones = $this->service->obtenerTodas($u['tipo'], $u['id']); + $totalNoLeidas = $this->service->contarNoLeidas($u['tipo'], $u['id']); + + return view('notificaciones.index', compact('notificaciones', 'totalNoLeidas')); + } + + /** + * POST /notificaciones/{id}/leer — Marcar una como leída + */ + public function marcarLeida(int $id) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $this->service->marcarLeida($id, $u['tipo'], $u['id']); + return response()->json(['ok' => true]); + } + + /** + * POST /notificaciones/leer-todas — Marcar todas como leídas + */ + public function marcarTodasLeidas() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $count = $this->service->marcarTodasLeidas($u['tipo'], $u['id']); + return response()->json(['ok' => true, 'marcadas' => $count]); + } + + /** + * GET /notificaciones/count — Badge AJAX (devuelve JSON {count: N}) + */ + public function count() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['count' => 0]); + + return response()->json(['count' => $this->service->contarNoLeidas($u['tipo'], $u['id'])]); + } + + /** + * GET /notificaciones/latest — ID de la última no leída + */ + public function latest() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['id' => 0]); + + $notif = $this->service->obtenerNoLeidas($u['tipo'], $u['id'])->first(); + return response()->json([ + 'id' => $notif ? $notif->id : 0, + 'titulo' => $notif ? $notif->titulo : '', + 'mensaje' => $notif ? $notif->mensaje : '' + ]); + } + + /** + * DELETE /notificaciones/{id} — Eliminar una notificación + */ + public function eliminar(int $id) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $this->service->eliminar($id, $u['tipo'], $u['id']); + return response()->json(['ok' => true]); + } + + /** + * DELETE /notificaciones — Eliminar todas + */ + public function eliminarTodas() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $this->service->eliminarTodas($u['tipo'], $u['id']); + return response()->json(['ok' => true]); + } + /** + * POST /notificaciones/subscribe — Guardar suscripción para Web Push + */ + public function subscribe(Request $request) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false, 'error' => 'No session'], 401); + + $request->validate([ + 'endpoint' => 'required', + 'keys.p256dh' => 'required', + 'keys.auth' => 'required', + ]); + + \App\Models\PushSubscription::updateOrCreate( + [ + 'id_usuario' => (string) $u['id'], + 'tipo_usuario' => $u['tipo'], + 'endpoint' => $request->endpoint, + ], + [ + 'p256dh' => $request->keys['p256dh'], + 'auth' => $request->keys['auth'], + ] + ); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/PanelController.php b/app/Http/Controllers/PanelController.php new file mode 100644 index 0000000..c92dd3a --- /dev/null +++ b/app/Http/Controllers/PanelController.php @@ -0,0 +1,363 @@ +notifService = $notifService; + } + + // ── Helper: obtener usuario logueado ── + private function getUser() + { + if (!session()->has('user_logged_in')) { + return null; + } + + $tipo = session('user_tipo'); + $id = session('user_id'); + + if ($tipo === 'jugador') { + return ['user' => Jugador::find($id), 'tipo' => $tipo]; + } + return ['user' => Aficionado::find($id), 'tipo' => $tipo]; + } + + // ══════════════════════════════════ + // PANEL PRINCIPAL + // ══════════════════════════════════ + public function index(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) { + session()->flush(); + return redirect('/?logout_msg=' . urlencode('Tu usuario no fue encontrado. Iniciá sesión nuevamente.')); + } + + $user = $data['user']; + $userTipo = $data['tipo']; + $userId = session('user_id'); + + // Cargar relaciones de club/equipos para jugadores + if ($userTipo === 'jugador') { + $user->load(['clubActual', 'equipos.club']); + } + + // Obtener QRs del usuario (eventos que NO estén borrados) + if ($userTipo === 'jugador') { + $qrCodes = QrCode::whereHas('evento') + ->where('id_jugador', $user->id_jugador) + ->orderBy('creado', 'desc') + ->get(); + } else { + $qrCodes = QrCode::whereHas('evento') + ->where('id_aficionado', $user->id_aficionado) + ->orderBy('creado', 'desc') + ->get(); + } + + // Obtener beneficios de promociones + $promoQrs = PromoQr::with('promocion') + ->where('id_usuario', $userId) + ->where('tipo_usuario', $userTipo) + ->orderBy('generado_en', 'desc') + ->get(); + + return view('panel.index', compact('user', 'userTipo', 'qrCodes', 'promoQrs')); + } + + // ══════════════════════════════════ + // ACTUALIZAR DATOS + // ══════════════════════════════════ + public function actualizarDatos(Request $request) + { + $data = $this->getUser(); + if (!$data) return redirect('/'); + + $request->validate([ + 'email' => 'required|email', + 'telefono' => 'required|string', + 'localidad' => 'nullable|string', + ]); + + $user = $data['user']; + + if ($data['tipo'] === 'jugador') { + $user->update([ + 'email' => $request->input('email'), + 'telefono' => $request->input('telefono'), + ]); + } else { + $user->update([ + 'email' => $request->input('email'), + 'telefono' => $request->input('telefono'), + 'localidad' => $request->input('localidad'), + ]); + } + + return back()->with('panel_msg', 'Datos actualizados correctamente.'); + } + + // ══════════════════════════════════ + // CAMBIAR CONTRASEÑA + // ══════════════════════════════════ + public function cambiarPassword(Request $request) + { + $data = $this->getUser(); + if (!$data) return redirect('/'); + + $request->validate([ + 'password_actual' => 'required|string', + 'password_nueva' => 'required|confirmed|min:6', + ]); + + $user = $data['user']; + + if ($user->password) { + if (!Hash::check($request->input('password_actual'), $user->password)) { + return back()->with('panel_error', 'La contraseña actual es incorrecta.'); + } + } + + $user->update([ + 'password' => bcrypt($request->input('password_nueva')), + ]); + + return back()->with('panel_msg', 'Contraseña cambiada correctamente.'); + } + + // ══════════════════════════════════ + // SOLICITAR QR PARA EVENTO + // ══════════════════════════════════ + public function solicitarQr(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + + $id_evento = $request->input('id_evento'); + $evento = Evento::with(['equipoLocal', 'equipoVisitante'])->find($id_evento); + + if (!$evento) { + return back()->with('panel_error', 'Evento no encontrado.'); + } + + $qrs_a_generar = 0; + + if ($userTipo === 'jugador') { + // Verificar si ya generó QRs para este evento + $yaGenero = QrCode::where('id_evento', $id_evento) + ->where('id_jugador', $user->id_jugador) + ->count(); + + if ($yaGenero > 0) { + return back()->with('panel_error', 'Ya solicitaste QRs para este evento.'); + } + + // Verificar si el jugador está activo + if (!$user->activo) { + return back()->with('panel_error', 'Tu registro no está activo. Completá tu registro primero.'); + } + + // Verificar si pertenece a alguno de los equipos del evento + $pertenece = DB::table('jugador_equipo') + ->where('id_jugador', $user->id_jugador) + ->whereIn('id_equipo', array_filter([$evento->id_equipo_local, $evento->id_equipo_visitante])) + ->exists(); + + if ($pertenece) { + $qrs_a_generar = $evento->limite_qr_jugador ?? 3; // Límite configurable + $tipoQr = 'invitado'; + } else { + // Check if category is "libre" + $edadCategoria = date('Y') - \Carbon\Carbon::parse($user->fecha_nacimiento)->format('Y'); + $categoriaDB = \App\Models\Categoria::where('edad_min', '<=', $edadCategoria) + ->where('edad_max', '>=', $edadCategoria) + ->first(); + + if ($categoriaDB && $categoriaDB->es_libre) { + $qrs_a_generar = 1; + $tipoQr = 'libre_50'; // Identificador para 50% de descuento + } else { + return back()->with('panel_error', 'No podés generar QR para este partido ya que no pertenecés a los equipos ni sos categoría Libre. Deberás abonar la totalidad de la entrada en puerta.'); + } + } + + if ($qrs_a_generar === 0) { + return back()->with('panel_error', 'No se permiten QRs para este evento.'); + } + + // Generar QRs + for ($i = 0; $i < $qrs_a_generar; $i++) { + QrCode::create([ + 'id_qr' => uniqid('qr_'), + 'id_evento' => $id_evento, + 'id_jugador' => $user->id_jugador, + 'tipo_qr' => $tipoQr ?? 'invitado', + 'escaneos_restantes' => 1, + 'creado' => now(), + ]); + } + + } else { + // Aficionado: no puede solicitar QRs para eventos + return back()->with('panel_error', 'Los aficionados no pueden solicitar QRs para eventos. Adquirí tu entrada directamente en el lugar.'); + } + + // Enviar mail con QRs + try { + Mail::to($user->email)->send(new QrCodeMail($user, $evento, $qrs_a_generar)); + } catch (\Exception $e) { + Log::error("Error enviando mail de QRs: " . $e->getMessage()); + } + + // Notificación interna + $this->notifService->enviar( + $userTipo, + $userTipo === 'jugador' ? $user->id_jugador : $user->id_aficionado, + 'sistema', + '🎫 QRs Generados', + "Tus {$qrs_a_generar} QR(s) para el evento " . ($evento->nombre_evento ?? 'seleccionado') . " ya están disponibles.", + route('panel.mis.qrs', ['evento' => $id_evento]) + ); + + return redirect()->route('panel.mis.qrs', ['evento' => $id_evento]) + ->with('panel_msg', "¡QR(s) generados correctamente! ({$qrs_a_generar})"); + } + + // ══════════════════════════════════ + // MIS QRS (per evento) + // ══════════════════════════════════ + public function misQrs(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + $id_evento = $request->query('evento'); + + // Traer QRs del usuario (solo de eventos activos) + $query = QrCode::whereHas('evento') + ->with(['evento.equipoLocal.club', 'evento.equipoVisitante.club', 'evento.torneo']); + + if ($userTipo === 'jugador') { + $query->where('id_jugador', $user->id_jugador); + $user->load('clubActual'); + } else { + $query->where('id_aficionado', $user->id_aficionado); + } + + if ($id_evento) { + $query->where('id_evento', $id_evento); + $evento = Evento::find($id_evento); + } else { + $evento = null; + } + + $qrs = $query->orderBy('creado', 'desc')->get(); + + // Adjuntar información de grupo si hay torneo + foreach ($qrs as $qr) { + if ($qr->evento && $qr->evento->id_torneo && $qr->evento->id_equipo_local) { + $rel = DB::table('torneo_equipo') + ->where('id_torneo', $qr->evento->id_torneo) + ->where('id_equipo', $qr->evento->id_equipo_local) + ->first(); + if ($rel) { + $qr->evento->grupo_nombre = $rel->grupo ?? 'General'; + } + } + } + + return view('panel.mis_qrs', compact('user', 'userTipo', 'qrs', 'evento')); + } + + // ══════════════════════════════════ + // GENERAR QR PARA PROMOCIÓN + // ══════════════════════════════════ + public function generarPromoQr(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + $userId = session('user_id'); + + $id_promo = $request->input('id_promo'); + $promo = Promocion::find($id_promo); + + if (!$promo) { + return back()->with('panel_error', 'Promoción no encontrada.'); + } + + // Verificar si ya generó QR para esta promo + $yaGenero = PromoQr::where('id_promo', $id_promo) + ->where('id_usuario', $userId) + ->where('tipo_usuario', $userTipo) + ->count(); + + if ($yaGenero > 0) { + return back()->with('panel_error', 'Ya generaste un QR para esta promoción.'); + } + + $id_qr = bin2hex(random_bytes(8)); + + PromoQr::create([ + 'id_qr' => $id_qr, + 'id_promo' => $id_promo, + 'id_usuario' => $userId, + 'tipo_usuario' => $userTipo, + 'generado_en' => now(), + 'usado' => false, + ]); + + return redirect()->route('panel.promo.qr.ver', ['id' => $id_qr]) + ->with('panel_msg', '¡QR de beneficio generado correctamente!'); + } + + // ══════════════════════════════════ + // VER QR DE PROMOCIÓN + // ══════════════════════════════════ + public function verPromoQr($id) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + + if ($userTipo === 'jugador') { + $user->load('clubActual'); + } + + $promoQr = PromoQr::with('promocion') + ->where('id_qr', $id) + ->where('id_usuario', session('user_id')) + ->where('tipo_usuario', $userTipo) + ->firstOrFail(); + + return view('panel.promo_qr', compact('promoQr', 'user', 'userTipo')); + } +} diff --git a/app/Http/Controllers/PromoQrController.php b/app/Http/Controllers/PromoQrController.php new file mode 100644 index 0000000..4f424c7 --- /dev/null +++ b/app/Http/Controllers/PromoQrController.php @@ -0,0 +1,52 @@ +get(); + return response()->json($promoQrs); + } + + public function show($id) + { + $promoQr = PromoQr::with('promocion', 'usuario')->findOrFail($id); + return response()->json($promoQr); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_promo' => 'required|integer|exists:promociones,id', + 'id_usuario' => 'required|integer', + 'tipo_usuario' => 'required|in:jugador,aficionado', + ]); + $data['id_qr'] = Str::uuid()->toString(); + $promoQr = PromoQr::create($data); + return response()->json($promoQr, 201); + } + + public function update(Request $request, $id) + { + $promoQr = PromoQr::findOrFail($id); + $data = $request->validate([ + 'usado' => 'nullable|boolean', + 'usado_en' => 'nullable', + ]); + $promoQr->update($data); + return response()->json($promoQr); + } + + public function destroy($id) + { + $promoQr = PromoQr::findOrFail($id); + $promoQr->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/PromocionController.php b/app/Http/Controllers/PromocionController.php new file mode 100644 index 0000000..9d97dfe --- /dev/null +++ b/app/Http/Controllers/PromocionController.php @@ -0,0 +1,70 @@ +expectsJson()) { + return response()->json($promociones); + } + + $categorias = $promociones->pluck('categoria')->filter()->unique()->values(); + + return view('promos.index', compact('promociones', 'categorias')); + } + + public function show($id) + { + $promocion = Promocion::with('promoQrs')->findOrFail($id); + return response()->json($promocion); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'direccion' => 'required|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen' => 'nullable|string|max:200', + ]); + $promocion = Promocion::create($data); + return response()->json($promocion, 201); + } + + public function update(Request $request, $id) + { + $promocion = Promocion::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'sometimes|string|max:100', + 'direccion' => 'sometimes|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen' => 'nullable|string|max:200', + ]); + $promocion->update($data); + return response()->json($promocion); + } + + public function destroy($id) + { + $promocion = Promocion::findOrFail($id); + $promocion->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/QrCodeController.php b/app/Http/Controllers/QrCodeController.php new file mode 100644 index 0000000..559b56b --- /dev/null +++ b/app/Http/Controllers/QrCodeController.php @@ -0,0 +1,57 @@ +get(); + return response()->json($qrCodes); + } + + public function show($id) + { + $qrCode = QrCode::with('evento', 'jugador', 'aficionado')->findOrFail($id); + return response()->json($qrCode); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_evento' => 'required|string|max:36', + 'id_jugador' => 'nullable|string|max:6', + 'tipo_qr' => 'required|in:invitado,publico', + 'escaneos_restantes' => 'nullable|integer|min:1', + 'id_aficionado' => 'nullable|integer', + ]); + $data['id_qr'] = Str::uuid()->toString(); + $qrCode = QrCode::create($data); + return response()->json($qrCode, 201); + } + + public function update(Request $request, $id) + { + $qrCode = QrCode::findOrFail($id); + $data = $request->validate([ + 'id_evento' => 'sometimes|string|max:36', + 'id_jugador' => 'nullable|string|max:6', + 'tipo_qr' => 'sometimes|in:invitado,publico', + 'escaneos_restantes' => 'nullable|integer|min:1', + 'id_aficionado' => 'nullable|integer', + ]); + $qrCode->update($data); + return response()->json($qrCode); + } + + public function destroy($id) + { + $qrCode = QrCode::findOrFail($id); + $qrCode->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/QrDownloadController.php b/app/Http/Controllers/QrDownloadController.php new file mode 100644 index 0000000..c606bd7 --- /dev/null +++ b/app/Http/Controllers/QrDownloadController.php @@ -0,0 +1,129 @@ +where('id_qr', $id) + ->firstOrFail(); + + // Verificar que el usuario tenga permiso para descargar este QR + $isUser = session()->has('user_logged_in') && session('user_logged_in'); + $isAdmin = session()->has('admin_logged_in') && session('admin_logged_in'); + + if ($isUser) { + $userId = session('user_id'); + $userTipo = session('user_tipo'); + if ($userTipo === 'jugador' && $qr->id_jugador != $userId) abort(403); + if ($userTipo === 'aficionado' && $qr->id_aficionado != $userId) abort(403); + } elseif (!$isAdmin) { + abort(403); + } + + $user = $qr->jugador ?: $qr->aficionado; + $userTipo = $qr->id_jugador ? 'jugador' : 'aficionado'; + + // Convertir imágenes a Base64 para DomPDF (más fiable que URLs remotas/locales) + $qrImageBase64 = ''; + try { + $qrUrl = "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=" . urlencode($qr->id_qr); + $qrContent = @file_get_contents($qrUrl); + if ($qrContent) { + $qrImageBase64 = 'data:image/png;base64,' . base64_encode($qrContent); + } + } catch (\Exception $e) { } + + $club = ($qr->evento && $qr->evento->equipoLocal && $qr->evento->equipoLocal->club) ? $qr->evento->equipoLocal->club : null; + + $backgroundBase64 = null; + if ($club && $club->qr_background) { + // 1. Intentar en storage/app/public (ruta interna Laravel) + $pathInStorage = str_replace(['storage/', 'storage\\'], '', $club->qr_background); + $bgPath = storage_path('app/public/' . $pathInStorage); + + // 2. Intentar en public_path (desarrollo local o symlink funcional) + if (!file_exists($bgPath)) { + $bgPath = public_path($club->qr_background); + } + + // 3. Intentar estructura Hostinger (public_html es hermano de la carpeta laravel) + if (!file_exists($bgPath)) { + $bgPath = base_path('../public_html/' . $club->qr_background); + } + + if (file_exists($bgPath)) { + $bgContent = @file_get_contents($bgPath); + if ($bgContent) { + $ext = pathinfo($bgPath, PATHINFO_EXTENSION); + $backgroundBase64 = 'data:image/' . $ext . ';base64,' . base64_encode($bgContent); + } + } + } + + $logoBase64 = null; + $logoPathFinal = null; + + // Logo del club del jugador (no del local) si corresponde: + // jugador → su club actual; aficionado/sin club → club local; fallback → logo OnAPB. + $logoClub = null; + if ($userTipo === 'jugador' && $qr->jugador && $qr->jugador->clubActual) { + $logoClub = $qr->jugador->clubActual; + } elseif ($club) { + $logoClub = $club; + } + + if ($logoClub && $logoClub->imagen) { + $logoInStorage = str_replace(['storage/', 'storage\\'], '', $logoClub->imagen); + $lPath = storage_path('app/public/' . $logoInStorage); + + if (!file_exists($lPath)) { + $lPath = public_path($logoClub->imagen); + } + + if (!file_exists($lPath)) { + $lPath = base_path('../public_html/' . $logoClub->imagen); + } + + if (file_exists($lPath)) { + $logoPathFinal = $lPath; + } + } + + // Si no hay logo, usar logo general + if (!$logoPathFinal) { + if (file_exists(public_path('logo.png'))) { + $logoPathFinal = public_path('logo.png'); + } + } + + if ($logoPathFinal) { + $logoContent = @file_get_contents($logoPathFinal); + if ($logoContent) { + $ext = pathinfo($logoPathFinal, PATHINFO_EXTENSION); + $logoBase64 = 'data:image/' . $ext . ';base64,' . base64_encode($logoContent); + } + } + + $data = [ + 'qr' => $qr, + 'user' => $user, + 'userTipo' => $userTipo, + 'club' => $club, + 'qrImageBase64' => $qrImageBase64, + 'backgroundBase64' => $backgroundBase64, + 'logoBase64' => $logoBase64, + ]; + + $pdf = Pdf::loadView('pdf.qr_ticket', $data) + ->setPaper([0, 0, 320, 500], 'portrait'); + + return $pdf->download("onapb_qr_{$qr->id_qr}.pdf"); + } +} diff --git a/app/Http/Controllers/SeguimientoController.php b/app/Http/Controllers/SeguimientoController.php new file mode 100644 index 0000000..7d7fb98 --- /dev/null +++ b/app/Http/Controllers/SeguimientoController.php @@ -0,0 +1,122 @@ +notifService = $notifService; + } + + private function getUserSession(): ?array + { + if (!session()->has('user_logged_in')) return null; + return ['tipo' => session('user_tipo'), 'id' => session('user_id')]; + } + + /** + * POST /seguimiento/equipo/{id} — Toggle: seguir o dejar de seguir un equipo + */ + public function toggle(int $id) + { + $u = $this->getUserSession(); + if (!$u) { + return response()->json(['ok' => false, 'msg' => 'Debés iniciar sesión para seguir equipos.'], 401); + } + + $equipo = Equipo::find($id); + if (!$equipo) { + return response()->json(['ok' => false, 'msg' => 'Equipo no encontrado.'], 404); + } + + $existing = EquipoSeguimiento::where('id_equipo', $id) + ->where('tipo_usuario', $u['tipo']) + ->where('id_usuario', (string)$u['id']) + ->first(); + + if ($existing) { + $existing->delete(); + return response()->json(['ok' => true, 'siguiendo' => false, 'msg' => 'Dejaste de seguir este equipo.']); + } + + EquipoSeguimiento::create([ + 'id_equipo' => $id, + 'tipo_usuario'=> $u['tipo'], + 'id_usuario' => (string)$u['id'], + 'created_at' => now(), + ]); + + return response()->json(['ok' => true, 'siguiendo' => true, 'msg' => '¡Ahora seguís este equipo!']); + } + + /** + * GET /seguimiento/mis-equipos — Devuelve equipos seguidos y próximos partidos + */ + public function misEquipos() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['equipos' => []]); + + $seguimientos = EquipoSeguimiento::with(['equipo.club']) + ->where('tipo_usuario', $u['tipo']) + ->where('id_usuario', (string)$u['id']) + ->get(); + + $equiposIds = $seguimientos->pluck('id_equipo'); + + // Próximos partidos de los equipos seguidos + $proximosPartidos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->where('fecha_evento', '>=', now()->toDateString()) + ->where(function ($q) use ($equiposIds) { + $q->whereIn('id_equipo_local', $equiposIds) + ->orWhereIn('id_equipo_visitante', $equiposIds); + }) + ->orderBy('fecha_evento') + ->orderBy('hora_inicio') + ->limit(10) + ->get(); + + return response()->json([ + 'equipos' => $seguimientos->map(fn($s) => [ + 'id' => $s->id_equipo, + 'nombre' => ($s->equipo->club->nombre ?? 'Equipo') . ' ' . $s->equipo->categoria . ($s->equipo->division ? ' ' . $s->equipo->division : ''), + 'club' => $s->equipo->club->nombre ?? '—', + 'categoria'=> $s->equipo->categoria, + 'division' => $s->equipo->division, + ]), + 'proximos_partidos' => $proximosPartidos->map(fn($e) => [ + 'id' => $e->id_evento, + 'fecha' => $e->fecha_evento->format('d/m/Y'), + 'hora' => $e->hora_inicio ? $e->hora_inicio->format('H:i') : '', + 'local' => $e->equipoLocal->club->nombre ?? '?', + 'visitante'=> $e->equipoVisitante->club->nombre ?? '?', + 'sede' => $e->sede, + ]), + ]); + } + + /** + * GET /seguimiento/estado/{id_equipo} — ¿El usuario sigue este equipo? + */ + public function estado(int $id) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['siguiendo' => false]); + + $siguiendo = EquipoSeguimiento::where('id_equipo', $id) + ->where('tipo_usuario', $u['tipo']) + ->where('id_usuario', (string)$u['id']) + ->exists(); + + return response()->json(['siguiendo' => $siguiendo]); + } +} diff --git a/app/Http/Controllers/TorneoController.php b/app/Http/Controllers/TorneoController.php new file mode 100644 index 0000000..d96bd0d --- /dev/null +++ b/app/Http/Controllers/TorneoController.php @@ -0,0 +1,85 @@ +query('grupo'); + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + $tournamentService = new \App\Services\TournamentService(); + $stats = $tournamentService->getStandings($id, true); + + $grupos = array_keys($stats); + + if ($selectedGroup && isset($stats[$selectedGroup])) { + $stats = [$selectedGroup => $stats[$selectedGroup]]; + } + + $followedTeamIds = []; + if (session('user_logged_in') && session('user_id')) { + $followedTeamIds = \App\Models\EquipoSeguimiento::where('id_usuario', session('user_id')) + ->where('tipo_usuario', session('user_tipo')) + ->pluck('id_equipo') + ->toArray(); + } + + return view('torneos.standings', compact('torneo', 'stats', 'grupos', 'selectedGroup', 'followedTeamIds')); + } + + public function topScorers(Request $request, $id) + { + $torneo = Torneo::findOrFail($id); + $selectedGroup = $request->query('grupo'); + + $query = \App\Models\EventoJugador::with(['jugador.clubActual']) + ->whereHas('evento', function($q) use ($id) { + $q->where('id_torneo', $id)->whereNotNull('marcador_local'); + }); + + if ($selectedGroup) { + $query->whereHas('evento.equipoLocal.torneos', function($q) use ($id, $selectedGroup) { + $q->where('torneos.id', $id)->where('torneo_equipo.grupo', $selectedGroup); + }); + } + + $scorers = $query->select('id_jugador', DB::raw('SUM(puntos) as total_puntos'), DB::raw('COUNT(id_evento) as partidos_jugados')) + ->groupBy('id_jugador') + ->orderByDesc('total_puntos') + ->take(20)->get(); + + $grupos = DB::table('torneo_equipo')->where('id_torneo', $id)->distinct()->pluck('grupo')->filter(); + + return view('torneos.scorers', compact('torneo', 'scorers', 'grupos', 'selectedGroup')); + } + + public function playoffs(Request $request, $id) + { + $selectedGroup = $request->query('grupo'); + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + // Get groups from pivot + $grupos = DB::table('torneo_equipo')->where('id_torneo', $id)->distinct()->pluck('grupo')->filter(); + + $ts = new \App\Services\TournamentService(); + $bracket = $ts->getPlayoffBrackets($id); + + // Map to simpler keys if needed by view + $bracket = [ + 'cuartos' => collect($bracket[\App\Models\Evento::FASE_CUARTOS] ?? []), + 'semis' => collect($bracket[\App\Models\Evento::FASE_SEMIS] ?? []), + 'final' => collect($bracket[\App\Models\Evento::FASE_FINAL] ?? []), + ]; + + return view('torneos.playoffs', compact('torneo', 'bracket', 'grupos', 'selectedGroup')); + } +} diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..440135b --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,40 @@ +headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-XSS-Protection', '1; mode=block'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set( + 'Permissions-Policy', + 'camera=(), microphone=(), geolocation=()' + ); + $response->headers->set( + 'Strict-Transport-Security', + 'max-age=31536000; includeSubDomains; preload' + ); + $response->headers->set( + 'Content-Security-Policy', + "default-src 'self'; " . + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; " . + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " . + "font-src 'self' data: https://fonts.gstatic.com; " . + "img-src 'self' data: https:; " . + "connect-src 'self' https://challenges.cloudflare.com; " . + "frame-src 'self' https://challenges.cloudflare.com; " . + "frame-ancestors 'self';" + ); + + return $response; + } +} diff --git a/app/Mail/QrCodeMail.php b/app/Mail/QrCodeMail.php new file mode 100644 index 0000000..d10222a --- /dev/null +++ b/app/Mail/QrCodeMail.php @@ -0,0 +1,29 @@ +user = $user; + $this->evento = $evento; + $this->cantidad = $cantidad; + } + + public function build() + { + return $this->subject('Tus QRs para el evento: ' . $this->evento->nombre_evento) + ->view('emails.qrcodes'); + } +} diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php new file mode 100644 index 0000000..4ca8203 --- /dev/null +++ b/app/Mail/ResetPasswordMail.php @@ -0,0 +1,27 @@ +user = $user; + $this->token = $token; + } + + public function build() + { + return $this->subject('Recuperar contraseña - OnAPB') + ->view('emails.reset_password'); + } +} diff --git a/app/Mail/WelcomeMail.php b/app/Mail/WelcomeMail.php new file mode 100644 index 0000000..f328ca4 --- /dev/null +++ b/app/Mail/WelcomeMail.php @@ -0,0 +1,27 @@ +user = $user; + $this->tipo = $tipo; + } + + public function build() + { + return $this->subject('¡Bienvenido a OnAPB!') + ->view('emails.welcome'); + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php new file mode 100644 index 0000000..0113782 --- /dev/null +++ b/app/Models/AdminUser.php @@ -0,0 +1,36 @@ +belongsTo(Club::class, 'id_club', 'id_club'); + } + + protected $hidden = [ + 'password', + 'reset_token', + ]; + + protected $casts = [ + 'role' => 'integer', + 'reset_expira' => 'datetime', + ]; +} diff --git a/app/Models/Aficionado.php b/app/Models/Aficionado.php new file mode 100644 index 0000000..003a298 --- /dev/null +++ b/app/Models/Aficionado.php @@ -0,0 +1,38 @@ + 'integer', + 'fecha_nacimiento' => 'date', + 'fecha_registro' => 'datetime', + 'reset_expira' => 'datetime', + ]; +} diff --git a/app/Models/AgentThread.php b/app/Models/AgentThread.php new file mode 100644 index 0000000..16b55cc --- /dev/null +++ b/app/Models/AgentThread.php @@ -0,0 +1,42 @@ + 'array', + 'expires_at' => 'datetime', + ]; + + public static function findOrCreateForAdmin(?string $threadId, int $adminId): static + { + if ($threadId) { + $thread = static::where('thread_id', $threadId) + ->where('admin_id', $adminId) + ->first(); + if ($thread) { + return $thread; + } + } + + return static::create([ + 'thread_id' => (string) Str::uuid(), + 'admin_id' => $adminId, + 'messages' => [], + 'expires_at' => now()->addDays(30), + ]); + } +} diff --git a/app/Models/CarouselItem.php b/app/Models/CarouselItem.php new file mode 100644 index 0000000..b2a2f7e --- /dev/null +++ b/app/Models/CarouselItem.php @@ -0,0 +1,25 @@ + 'boolean', + 'orden' => 'integer', + ]; +} diff --git a/app/Models/Categoria.php b/app/Models/Categoria.php new file mode 100644 index 0000000..9d9a672 --- /dev/null +++ b/app/Models/Categoria.php @@ -0,0 +1,19 @@ +id_club)) { + $model->id_club = (int) self::withTrashed()->max('id_club') + 1; + } + }); + } + + protected $casts = [ + 'id_club' => 'integer', + 'es_seleccion' => 'boolean', + ]; + + public function equipos() + { + return $this->hasMany(Equipo::class, 'id_club', 'id_club'); + } + + public function jugadores() + { + return $this->hasMany(Jugador::class, 'id_club_actual', 'id_club'); + } +} diff --git a/app/Models/Configuracion.php b/app/Models/Configuracion.php new file mode 100644 index 0000000..5c77e24 --- /dev/null +++ b/app/Models/Configuracion.php @@ -0,0 +1,45 @@ +first(); + return $config ? $config->valor : $default; + } + + /** + * Establecer o actualizar un valor de configuración. + * + * @param string $clave + * @param mixed $valor + * @param string|null $descripcion + * @return self + */ + public static function set($clave, $valor, $descripcion = null) + { + return self::updateOrCreate( + ['clave' => $clave], + ['valor' => $valor, 'descripcion' => $descripcion] + ); + } +} diff --git a/app/Models/Equipo.php b/app/Models/Equipo.php new file mode 100644 index 0000000..bb0d9c2 --- /dev/null +++ b/app/Models/Equipo.php @@ -0,0 +1,41 @@ + 'integer', + 'id_club' => 'integer', + ]; + + public function club() + { + return $this->belongsTo(Club::class, 'id_club', 'id_club'); + } + + public function jugadores() + { + return $this->belongsToMany(Jugador::class, 'jugador_equipo', 'id_equipo', 'id_jugador') + ->withPivot('fecha_alta'); + } + + public function torneos() + { + return $this->belongsToMany(Torneo::class, 'torneo_equipo', 'id_equipo', 'id_torneo'); + } +} diff --git a/app/Models/EquipoSeguimiento.php b/app/Models/EquipoSeguimiento.php new file mode 100644 index 0000000..79e1dc4 --- /dev/null +++ b/app/Models/EquipoSeguimiento.php @@ -0,0 +1,28 @@ + 'integer', + 'created_at' => 'datetime', + ]; + + public function equipo() + { + return $this->belongsTo(Equipo::class, 'id_equipo', 'id_equipo'); + } +} diff --git a/app/Models/Evento.php b/app/Models/Evento.php new file mode 100644 index 0000000..85c24b0 --- /dev/null +++ b/app/Models/Evento.php @@ -0,0 +1,96 @@ + 'string', + 'id_torneo' => 'integer', + 'id_equipo_local' => 'integer', + 'id_equipo_visitante' => 'integer', + 'marcador_local' => 'integer', + 'marcador_visitante' => 'integer', + 'precio' => 'decimal:2', + 'fase' => 'integer', + 'numero_partido_bracket' => 'integer', + ]; + + public function getFechaEventoAttribute($value) + { + return $value ? Carbon::parse($value) : null; + } + + public function getHoraInicioAttribute($value) + { + return $value ? Carbon::parse($value) : null; + } + + public function getHoraFinAttribute($value) + { + return $value ? Carbon::parse($value) : null; + } + + public function torneo() + { + return $this->belongsTo(Torneo::class, 'id_torneo'); + } + + public function equipoLocal() + { + return $this->belongsTo(Equipo::class, 'id_equipo_local', 'id_equipo'); + } + + public function equipoVisitante() + { + return $this->belongsTo(Equipo::class, 'id_equipo_visitante', 'id_equipo'); + } + + public function pagos() + { + return $this->hasMany(PagoMp::class, 'event_id', 'id_evento'); + } + + public function qrCodes() + { + return $this->hasMany(QrCode::class, 'id_evento', 'id_evento'); + } + + public function jugadoresPuntos() + { + return $this->hasMany(EventoJugador::class, 'id_evento', 'id_evento'); + } +} diff --git a/app/Models/EventoJugador.php b/app/Models/EventoJugador.php new file mode 100644 index 0000000..18dd668 --- /dev/null +++ b/app/Models/EventoJugador.php @@ -0,0 +1,27 @@ +belongsTo(Evento::class, 'id_evento', 'id_evento'); + } + + public function jugador() + { + return $this->belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } +} diff --git a/app/Models/Jugador.php b/app/Models/Jugador.php new file mode 100644 index 0000000..8bd86a8 --- /dev/null +++ b/app/Models/Jugador.php @@ -0,0 +1,79 @@ + 'string', + 'fecha_nacimiento' => 'date', + 'edad' => 'integer', + 'id_club_actual' => 'integer', + 'id_club_origen' => 'integer', + 'activo' => 'boolean', + 'reset_expira' => 'datetime', + ]; + + public function getCategoriaCalculadaAttribute() + { + if (!$this->fecha_nacimiento) return 'Sin categoría'; + + // Calculate age for the current year. (Categoría U15 is for players turning 14 and 15 in the current year). + // That means current_year - birth_year + $edadCategoria = date('Y') - $this->fecha_nacimiento->format('Y'); + + $categoria = Categoria::where('edad_min', '<=', $edadCategoria) + ->where('edad_max', '>=', $edadCategoria) + ->first(); + + return $categoria ? $categoria->nombre : 'Sin categoría'; + } + + public function clubActual() + { + return $this->belongsTo(Club::class, 'id_club_actual', 'id_club'); + } + + public function clubOrigen() + { + return $this->belongsTo(Club::class, 'id_club_origen', 'id_club'); + } + + public function equipos() + { + return $this->belongsToMany(Equipo::class, 'jugador_equipo', 'id_jugador', 'id_equipo') + ->withPivot('fecha_alta'); + } +} diff --git a/app/Models/JugadorEquipo.php b/app/Models/JugadorEquipo.php new file mode 100644 index 0000000..2efefa6 --- /dev/null +++ b/app/Models/JugadorEquipo.php @@ -0,0 +1,34 @@ + 'string', + 'id_equipo' => 'integer', + 'fecha_alta' => 'date', + ]; + + public function jugador() + { + return $this->belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } + + public function equipo() + { + return $this->belongsTo(Equipo::class, 'id_equipo', 'id_equipo'); + } +} diff --git a/app/Models/Noticia.php b/app/Models/Noticia.php new file mode 100644 index 0000000..f275db4 --- /dev/null +++ b/app/Models/Noticia.php @@ -0,0 +1,26 @@ + 'integer', + 'fecha' => 'datetime', + ]; +} diff --git a/app/Models/Notificacion.php b/app/Models/Notificacion.php new file mode 100644 index 0000000..1dcb753 --- /dev/null +++ b/app/Models/Notificacion.php @@ -0,0 +1,40 @@ + 'boolean', + 'enviada_email' => 'boolean', + 'creada_en' => 'datetime', + ]; + + // ── Scopes ── + public function scopeNoLeidas($query) + { + return $query->where('leida', false); + } + + public function scopeParaUsuario($query, string $tipo, $id) + { + return $query->where('tipo_destinatario', $tipo)->where('id_destinatario', (string)$id); + } +} diff --git a/app/Models/Pase.php b/app/Models/Pase.php new file mode 100644 index 0000000..0f0283e --- /dev/null +++ b/app/Models/Pase.php @@ -0,0 +1,33 @@ +belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } + + public function clubOrigen() + { + return $this->belongsTo(Club::class, 'id_club_origen', 'id_club'); + } + + public function clubDestino() + { + return $this->belongsTo(Club::class, 'id_club_destino', 'id_club'); + } +} diff --git a/app/Models/PromoQr.php b/app/Models/PromoQr.php new file mode 100644 index 0000000..e3287a7 --- /dev/null +++ b/app/Models/PromoQr.php @@ -0,0 +1,46 @@ + 'string', + 'id_promo' => 'integer', + 'id_usuario' => 'integer', + 'tipo_usuario' => 'string', + 'generado_en' => 'datetime', + 'usado' => 'boolean', + 'usado_en' => 'datetime', + ]; + + public function promocion() + { + return $this->belongsTo(Promocion::class, 'id_promo', 'id'); + } + + public function usuario() + { + if ($this->tipo_usuario === 'jugador') { + return $this->belongsTo(Jugador::class, 'id_usuario', 'id_jugador'); + } + return $this->belongsTo(Aficionado::class, 'id_usuario', 'id_aficionado'); + } +} diff --git a/app/Models/Promocion.php b/app/Models/Promocion.php new file mode 100644 index 0000000..3896198 --- /dev/null +++ b/app/Models/Promocion.php @@ -0,0 +1,35 @@ + 'integer', + 'lat' => 'decimal:8', + 'lng' => 'decimal:8', + ]; + + public function promoQrs() + { + return $this->hasMany(PromoQr::class, 'id_promo', 'id'); + } +} diff --git a/app/Models/PushSubscription.php b/app/Models/PushSubscription.php new file mode 100644 index 0000000..c137170 --- /dev/null +++ b/app/Models/PushSubscription.php @@ -0,0 +1,24 @@ +where('tipo_usuario', $tipo)->where('id_usuario', $id); + } +} diff --git a/app/Models/QrCode.php b/app/Models/QrCode.php new file mode 100644 index 0000000..484396a --- /dev/null +++ b/app/Models/QrCode.php @@ -0,0 +1,48 @@ + 'string', + 'id_evento' => 'string', + 'id_jugador' => 'string', + 'tipo_qr' => 'string', + 'escaneos_restantes' => 'integer', + 'creado' => 'datetime', + 'id_aficionado' => 'integer', + ]; + + public function evento() + { + return $this->belongsTo(Evento::class, 'id_evento', 'id_evento'); + } + + public function jugador() + { + return $this->belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } + + public function aficionado() + { + return $this->belongsTo(Aficionado::class, 'id_aficionado', 'id_aficionado'); + } +} diff --git a/app/Models/Sponsor.php b/app/Models/Sponsor.php new file mode 100644 index 0000000..2fb3743 --- /dev/null +++ b/app/Models/Sponsor.php @@ -0,0 +1,23 @@ + 'boolean', + ]; +} diff --git a/app/Models/Torneo.php b/app/Models/Torneo.php new file mode 100644 index 0000000..6447d9d --- /dev/null +++ b/app/Models/Torneo.php @@ -0,0 +1,33 @@ + 'datetime', + 'fecha_fin' => 'datetime', + ]; + + public function equipos() + { + return $this->belongsToMany(Equipo::class, 'torneo_equipo', 'id_torneo', 'id_equipo')->withPivot('grupo'); + } + + public function eventos() + { + return $this->hasMany(Evento::class, 'id_torneo'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..68f3a66 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,49 @@ + */ + use HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/app/Observers/EventoObserver.php b/app/Observers/EventoObserver.php new file mode 100644 index 0000000..e28c8d3 --- /dev/null +++ b/app/Observers/EventoObserver.php @@ -0,0 +1,142 @@ +notifService = $notifService; + } + + /** + * Al CREAR un partido: notificar a todos los seguidores de ambos equipos. + */ + public function created(Evento $evento): void + { + if (!$evento->id_equipo_local || !$evento->id_equipo_visitante) return; + + $evento->load(['equipoLocal.club', 'equipoVisitante.club']); + + $nombreLocal = $evento->equipoLocal->club->nombre ?? '?'; + $nombreVisitante= $evento->equipoVisitante->club->nombre ?? '?'; + $fechaStr = $evento->fecha_evento ? $evento->fecha_evento->format('d/m/Y') : '—'; + $horaStr = $evento->hora_inicio ? \Carbon\Carbon::parse($evento->hora_inicio)->format('H:i') : ''; + $sedeStr = $evento->sede ? " en {$evento->sede}" : ''; + + $titulo = "🏀 Nuevo Partido Programado"; + $mensaje = "{$nombreLocal} vs {$nombreVisitante} — {$fechaStr}" . ($horaStr ? " a las {$horaStr}" : '') . $sedeStr . '.'; + $url = '/eventos/' . $evento->id_evento; + + $this->notificarSeguidoresDeEquipos( + [$evento->id_equipo_local, $evento->id_equipo_visitante], + 'partido', + $titulo, + $mensaje, + $url + ); + } + + /** + * Al ACTUALIZAR un partido: si cambia el marcador, notificar resultado. + */ + public function updated(Evento $evento): void + { + $dirty = $evento->getDirty(); + + // Solo disparar si se actualizó el marcador + if (!array_key_exists('marcador_local', $dirty) && !array_key_exists('marcador_visitante', $dirty)) { + return; + } + + if ($evento->marcador_local === null || $evento->marcador_visitante === null) return; + + // Validación Horaria (Buenos Aires) + $tz = 'America/Argentina/Buenos_Aires'; + $ahora = \Carbon\Carbon::now($tz); + + // El usuario solicita que se emita cuando fin > real + $fecha = $evento->fecha_evento instanceof \Carbon\Carbon ? $evento->fecha_evento->format('Y-m-d') : substr($evento->fecha_evento, 0, 10); + $horaFin = $evento->hora_fin instanceof \Carbon\Carbon ? $evento->hora_fin->format('H:i:s') : $evento->hora_fin; + $fin = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$fecha} {$horaFin}", $tz); + + if (!($fin->gt($ahora))) { + return; + } + + $evento->load(['equipoLocal.club', 'equipoVisitante.club']); + + $nombreLocal = $evento->equipoLocal->club->nombre ?? '?'; + $nombreVisitante = $evento->equipoVisitante->club->nombre ?? '?'; + $mloc = $evento->marcador_local; + $mvis = $evento->marcador_visitante; + + if ($mloc > $mvis) { + $resultado = "🏆 Ganó {$nombreLocal}"; + } elseif ($mvis > $mloc) { + $resultado = "🏆 Ganó {$nombreVisitante}"; + } else { + $resultado = "🤝 Empate"; + } + + $titulo = "Resultado: {$nombreLocal} {$mloc} - {$mvis} {$nombreVisitante}"; + $mensaje = "{$resultado}. Partido finalizado."; + $url = '/eventos/' . $evento->id_evento; + + $this->notificarSeguidoresDeEquipos( + [$evento->id_equipo_local, $evento->id_equipo_visitante], + 'resultado', + $titulo, + $mensaje, + $url + ); + } + + /** + * Obtiene todos los seguidores de una lista de equipos y envía notificaciones. + * También incluye a los jugadores de esos equipos automáticamente. + */ + private function notificarSeguidoresDeEquipos(array $idEquipos, string $tipo, string $titulo, string $mensaje, ?string $url): void + { + $idEquipos = array_filter($idEquipos); + if (empty($idEquipos)) return; + + $destinatarios = []; + $yaAgregados = []; + + // Seguidores registrados en equipo_seguimiento + $seguimientos = EquipoSeguimiento::whereIn('id_equipo', $idEquipos)->get(); + foreach ($seguimientos as $s) { + $key = $s->tipo_usuario . ':' . $s->id_usuario; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => $s->tipo_usuario, 'id' => $s->id_usuario]; + $yaAgregados[$key] = true; + } + } + + // Jugadores que pertenecen a estos equipos (siguen automáticamente) + $jugadores = \DB::table('jugador_equipo') + ->whereIn('id_equipo', $idEquipos) + ->pluck('id_jugador'); + + foreach ($jugadores as $idJ) { + $key = 'jugador:' . $idJ; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => 'jugador', 'id' => $idJ]; + $yaAgregados[$key] = true; + } + } + + if (!empty($destinatarios)) { + $this->notifService->enviarMasivo($destinatarios, $tipo, $titulo, $mensaje, $url); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..f9857ed --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,57 @@ +app->singleton(NotificacionService::class); + } + + public function boot(): void + { + \Illuminate\Pagination\Paginator::useBootstrapFive(); + + // Registrar observer del modelo Evento + Evento::observe(EventoObserver::class); + + view()->composer('layouts.app', function ($view) { + try { + if (\Illuminate\Support\Facades\Schema::hasTable('sponsors')) { + $view->with('footerSponsors', \App\Models\Sponsor::where('activo', true)->orderBy('orden')->get()); + } else { + $view->with('footerSponsors', collect()); + } + + if (\Illuminate\Support\Facades\Schema::hasTable('torneos')) { + $view->with('navTorneos', \App\Models\Torneo::orderByDesc('fecha_inicio')->take(5)->get()); + } else { + $view->with('navTorneos', collect()); + } + + // Badge de notificaciones para el layout + if (session()->has('user_logged_in') && \Illuminate\Support\Facades\Schema::hasTable('notificaciones')) { + $service = app(NotificacionService::class); + $view->with('notifCount', $service->contarNoLeidas(session('user_tipo'), session('user_id'))); + } else { + $view->with('notifCount', 0); + } + + } catch (\Exception $e) { + $view->with('footerSponsors', collect()); + $view->with('navTorneos', collect()); + $view->with('notifCount', 0); + } + }); + } +} + diff --git a/app/Services/FixtureService.php b/app/Services/FixtureService.php new file mode 100644 index 0000000..5e0b581 --- /dev/null +++ b/app/Services/FixtureService.php @@ -0,0 +1,138 @@ +equipos()->get()->values()->toArray(); + $n = count($equipos); + + if ($n < 2) { + throw new \InvalidArgumentException('Se necesitan al menos 2 equipos para generar un fixture.'); + } + + // Si número impar, agregar BYE + $hayBye = false; + if ($n % 2 !== 0) { + $equipos[] = null; // BYE + $n++; + $hayBye = true; + } + + $mitad = $n / 2; + $jornadas = $n - 1; + $partidos = []; + $fechaActual = Carbon::parse($fechaInicio); + + // Algoritmo Round-Robin: fijar el primer equipo, rotar el resto + $lista = range(0, $n - 1); + + for ($jornada = 0; $jornada < $jornadas; $jornada++) { + $jornadaPartidos = []; + + for ($i = 0; $i < $mitad; $i++) { + $localIdx = $lista[$i]; + $visitanteIdx= $lista[$n - 1 - $i]; + + // Si alguno es BYE (null), saltar + if ($equipos[$localIdx] === null || $equipos[$visitanteIdx] === null) continue; + + $local = $equipos[$localIdx]; + $visitante = $equipos[$visitanteIdx]; + + // Alternar localía: en jornadas pares, invertir + if ($jornada % 2 === 1) { + [$local, $visitante] = [$visitante, $local]; + } + + $jornadaPartidos[] = [ + 'id_equipo_local' => $local['id_equipo'], + 'id_equipo_visitante' => $visitante['id_equipo'], + 'fecha_evento' => $fechaActual->format('Y-m-d'), + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'sede' => $sedeDefault, + 'id_torneo' => $torneo->id, + 'nombre_evento' => null, // se genera automáticamente + 'precio' => 0, + 'jornada' => $jornada + 1, + ]; + } + + $partidos = array_merge($partidos, $jornadaPartidos); + $fechaActual->addDays($diasEntreJornadas); + + // Rotar la lista (el primer elemento fijo) + $rotatable = array_slice($lista, 1); + array_push($rotatable, array_shift($rotatable)); + $lista = array_merge([$lista[0]], $rotatable); + } + + // Doble rueda: agregar vuelta con localías invertidas + if ($dobleRueda && !empty($partidos)) { + $vuelta = []; + foreach ($partidos as $p) { + $vuelta[] = array_merge($p, [ + 'id_equipo_local' => $p['id_equipo_visitante'], + 'id_equipo_visitante' => $p['id_equipo_local'], + 'fecha_evento' => Carbon::parse($p['fecha_evento'])->addDays($jornadas * $diasEntreJornadas)->format('Y-m-d'), + 'jornada' => $p['jornada'] + $jornadas, + ]); + } + $partidos = array_merge($partidos, $vuelta); + } + + return $partidos; + } + + /** + * Persiste el fixture generado como Evento records en la DB. + */ + public function persistirFixture(array $partidos, Torneo $torneo): int + { + $contador = 0; + foreach ($partidos as $p) { + $local = Equipo::with('club')->find($p['id_equipo_local']); + $visitante = Equipo::with('club')->find($p['id_equipo_visitante']); + + $nombreEvento = ($local->club->nombre ?? '?') . ' vs ' . ($visitante->club->nombre ?? '?'); + + \App\Models\Evento::create([ + 'id_evento' => uniqid('ev_'), + 'nombre_evento' => $nombreEvento, + 'id_equipo_local' => $p['id_equipo_local'], + 'id_equipo_visitante' => $p['id_equipo_visitante'], + 'fecha_evento' => $p['fecha_evento'], + 'hora_inicio' => $p['hora_inicio'], + 'hora_fin' => $p['hora_fin'], + 'sede' => $p['sede'] ?: null, + 'id_torneo' => $torneo->id, + 'precio' => $p['precio'] ?? 0, + ]); + + $contador++; + } + return $contador; + } +} diff --git a/app/Services/GeniusAgentService.php b/app/Services/GeniusAgentService.php new file mode 100644 index 0000000..f079732 --- /dev/null +++ b/app/Services/GeniusAgentService.php @@ -0,0 +1,229 @@ +buildMessages($history, $message); + + try { + $response = Prism::text() + ->using(self::PROVIDER, $this->model()) + ->withSystemPrompt(SystemPromptPublic::get()) + ->withMessages($messages) + ->asText(); + + return $response->text; + } catch (PrismRateLimitedException $e) { + report($e); + return 'Demasiadas consultas en poco tiempo. Por favor, esperá un momento e intentá de nuevo.'; + } catch (Throwable $e) { + report($e); + return 'El agente no responde en este momento. Por favor, intentá de nuevo en unos instantes.'; + } + } + + public function chatAdmin(string $message, AgentThread $thread, bool $isSuperadmin = false): string + { + $messages = $this->buildMessages($thread->messages ?? [], $message); + + try { + $response = Prism::text() + ->using(self::PROVIDER, $this->model()) + ->withSystemPrompt(SystemPromptAdmin::get($isSuperadmin)) + ->withMessages($messages) + ->withTools($this->getAdminTools($isSuperadmin)) + ->withMaxSteps(self::MAX_STEPS) + ->asText(); + + if ($response->text !== '') { + $reply = $response->text; + } else { + Log::warning('Genius admin: empty text from model', [ + 'finishReason' => isset($response->finishReason) + ? (is_object($response->finishReason) ? ($response->finishReason->name ?? get_class($response->finishReason)) : $response->finishReason) + : null, + 'steps_count' => is_countable($response->steps ?? null) ? count($response->steps) : null, + 'tool_calls' => is_countable($response->toolCalls ?? null) ? count($response->toolCalls) : null, + 'tool_results' => is_countable($response->toolResults ?? null) ? count($response->toolResults) : null, + ]); + $reply = $this->fallbackFromToolResults($response); + } + + $updated = array_merge($thread->messages ?? [], [ + ['role' => 'user', 'content' => $message], + ['role' => 'assistant', 'content' => $reply], + ]); + + $thread->update(['messages' => $updated]); + + return $reply; + } catch (PrismRateLimitedException $e) { + report($e); + return 'Demasiadas consultas en poco tiempo. Por favor, esperá un momento e intentá de nuevo.'; + } catch (Throwable $e) { + report($e); + return 'El agente no responde en este momento. Por favor, intentá de nuevo en unos instantes.'; + } + } + + private function fallbackFromToolResults($response): string + { + $collected = []; + + $steps = $response->steps ?? []; + foreach ($steps as $step) { + if (!empty($step->text ?? '')) { + return $step->text; + } + + foreach (($step->toolResults ?? []) as $tr) { + $collected[] = [ + 'tool' => $tr->toolName ?? ($tr->name ?? 'tool'), + 'result' => $tr->result ?? null, + ]; + } + } + + if (empty($collected)) { + foreach (($response->toolResults ?? []) as $tr) { + $collected[] = [ + 'tool' => $tr->toolName ?? ($tr->name ?? 'tool'), + 'result' => $tr->result ?? null, + ]; + } + } + + if (empty($collected)) { + return 'El agente no devolvió una respuesta. Revisá storage/logs/laravel.log para el detalle.'; + } + + $lines = ["Datos obtenidos (la IA no generó un resumen en texto):"]; + foreach ($collected as $item) { + $decoded = is_string($item['result']) ? json_decode($item['result'], true) : $item['result']; + $pretty = is_array($decoded) || is_object($decoded) + ? json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + : (string) $item['result']; + $lines[] = "\n**{$item['tool']}**:\n{$pretty}"; + } + + return implode("\n", $lines); + } + + /** @return array */ + private function buildMessages(array $history, string $newMessage): array + { + $limit = (int) config('services.genius.history_limit', 10); + $trimmed = $limit > 0 ? array_slice($history, -$limit * 2) : $history; + + $messages = []; + + foreach ($trimmed as $entry) { + if (($entry['role'] ?? '') === 'user') { + $messages[] = new UserMessage($entry['content']); + } elseif (($entry['role'] ?? '') === 'assistant') { + $messages[] = new AssistantMessage($entry['content']); + } + } + + $messages[] = new UserMessage($newMessage); + + return $messages; + } + + /** @return array */ + private function getAdminTools(bool $isSuperadmin): array + { + $readOnly = [ + Tool::as('listar_torneos') + ->for('Lista todos los torneos del sistema con sus IDs y fechas. Úsalo cuando el admin mencione un torneo por nombre para obtener el id_torneo.') + ->using(new ListarTorneosTool()), + + Tool::as('listar_equipos') + ->for('Lista los equipos del sistema. Acepta filtros opcionales por torneo (id_torneo) y grupo.') + ->withNumberParameter('id_torneo', 'ID del torneo para filtrar equipos', false) + ->withStringParameter('grupo', 'Nombre del grupo (ej. "A", "B") para filtrar dentro del torneo', false) + ->using(new ListarEquiposTool()), + + Tool::as('listar_eventos') + ->for('Lista los partidos/eventos del sistema. Acepta filtros opcionales por rango de fechas y torneo.') + ->withStringParameter('fecha_desde', 'Fecha de inicio del rango (formato YYYY-MM-DD)', false) + ->withStringParameter('fecha_hasta', 'Fecha de fin del rango (formato YYYY-MM-DD)', false) + ->withNumberParameter('id_torneo', 'ID del torneo para filtrar eventos', false) + ->using(new ListarEventosTool()), + ]; + + if (!$isSuperadmin) { + return $readOnly; + } + + $writeTools = [ + Tool::as('crear_partido') + ->for('Crea un nuevo partido en el sistema. SOLO ejecutar tras confirmación explícita del superadmin.') + ->withNumberParameter('id_equipo_local', 'ID del equipo local') + ->withNumberParameter('id_equipo_visitante', 'ID del equipo visitante') + ->withStringParameter('fecha_evento', 'Fecha del partido (formato YYYY-MM-DD)') + ->withStringParameter('hora_inicio', 'Hora de inicio (formato HH:MM)') + ->withStringParameter('hora_fin', 'Hora de fin (formato HH:MM)') + ->withStringParameter('sede', 'Nombre de la cancha o sede del partido') + ->withNumberParameter('id_torneo', 'ID del torneo al que pertenece el partido') + ->using(new CrearPartidoTool()), + + Tool::as('cargar_puntaje') + ->for('Actualiza el marcador de un partido existente. SOLO ejecutar tras confirmación explícita del superadmin.') + ->withStringParameter('id_evento', 'UUID del partido a actualizar') + ->withNumberParameter('marcador_local', 'Puntos del equipo local') + ->withNumberParameter('marcador_visitante', 'Puntos del equipo visitante') + ->using(new CargarPuntajeTool()), + + Tool::as('redactar_noticia') + ->for('Publica una noticia en el portal OnAPB. SOLO ejecutar tras confirmación explícita del superadmin.') + ->withStringParameter('titulo', 'Título de la noticia') + ->withStringParameter('contenido', 'Cuerpo completo de la noticia en HTML o texto plano') + ->withNumberParameter('id_torneo', 'ID del torneo relacionado (opcional)', false) + ->withStringParameter('categoria', 'Categoría de la noticia (opcional)', false) + ->using(new RedactarNoticiaTool()), + + Tool::as('eliminar_noticia') + ->for('Elimina (rollback) una noticia publicada. Usar cuando el superadmin pida deshacer una creación.') + ->withNumberParameter('id_noticia', 'ID numérico de la noticia a eliminar') + ->using(new EliminarNoticiaTool()), + + Tool::as('eliminar_partido') + ->for('Elimina (soft delete / rollback) un partido creado. Usar cuando el superadmin pida deshacer.') + ->withStringParameter('id_evento', 'UUID del partido a eliminar') + ->using(new EliminarPartidoTool()), + ]; + + return array_merge($readOnly, $writeTools); + } +} diff --git a/app/Services/ImageOptimizer.php b/app/Services/ImageOptimizer.php new file mode 100644 index 0000000..4c69f5f --- /dev/null +++ b/app/Services/ImageOptimizer.php @@ -0,0 +1,106 @@ + ['maxWidth' => 1600, 'quality' => 82], + 'noticias' => ['maxWidth' => 1200, 'quality' => 82], + 'promos' => ['maxWidth' => 1200, 'quality' => 82], + 'clubes' => ['maxWidth' => 512, 'quality' => 85], + 'sponsors' => ['maxWidth' => 600, 'quality' => 85], + 'qr' => ['maxWidth' => 800, 'quality' => 85], + ]; + + private const MIN_RECOMPRESS_BYTES = 100 * 1024; + + /** + * Sube el archivo al disk 'public' y lo optimiza in-place. + * Retorna el path relativo (ej: "carousel/abc123.jpg"). + */ + public function storeAndOptimize(UploadedFile $file, string $folder): string + { + $path = $file->store($folder, 'public'); + + if (!extension_loaded('gd')) { + return $path; + } + + $cfg = self::FOLDERS[$folder] ?? null; + if (!$cfg) { + return $path; + } + + $abs = Storage::disk('public')->path($path); + $this->optimizeFile($abs, $cfg['maxWidth'], $cfg['quality']); + + return $path; + } + + /** + * Optimiza un archivo en disco (resize + recompress). + * Devuelve [origSize, newSize, newW, newH] o null si no se modifico. + */ + public function optimizeFile(string $file, int $maxWidth, int $quality): ?array + { + if (!file_exists($file)) return null; + + $origSize = filesize($file); + $info = @getimagesize($file); + if (!$info) return null; + + [$w, $h] = $info; + $needsResize = $w > $maxWidth; + $needsRecomp = $origSize > self::MIN_RECOMPRESS_BYTES; + if (!$needsResize && !$needsRecomp) return null; + + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $img = match ($ext) { + 'jpg', 'jpeg' => @imagecreatefromjpeg($file), + 'png' => @imagecreatefrompng($file), + 'webp' => @imagecreatefromwebp($file), + default => null, + }; + if (!$img) return null; + + $newW = $w; $newH = $h; + if ($w > $maxWidth) { + $newW = $maxWidth; + $newH = (int) round($h * ($maxWidth / $w)); + $resized = imagecreatetruecolor($newW, $newH); + + if (in_array($ext, ['png', 'webp'])) { + imagealphablending($resized, false); + imagesavealpha($resized, true); + $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127); + imagefilledrectangle($resized, 0, 0, $newW, $newH, $transparent); + } + + imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + $img = $resized; + } + + ob_start(); + match ($ext) { + 'jpg', 'jpeg' => imagejpeg($img, null, $quality), + 'png' => imagepng($img, null, 9), + 'webp' => imagewebp($img, null, $quality), + }; + $bytes = ob_get_clean(); + + if (strlen($bytes) >= $origSize) { + return null; + } + + file_put_contents($file, $bytes); + return [$origSize, strlen($bytes), $newW, $newH]; + } +} diff --git a/app/Services/NotificacionService.php b/app/Services/NotificacionService.php new file mode 100644 index 0000000..985efe1 --- /dev/null +++ b/app/Services/NotificacionService.php @@ -0,0 +1,211 @@ + $tipo_dest, + 'id_destinatario' => (string) $id_dest, + 'tipo' => $tipo, + 'titulo' => $titulo, + 'mensaje' => $mensaje, + 'url_accion' => $url, + 'leida' => false, + 'enviada_email' => false, + 'creada_en' => now(), + ]); + + // Intentar envío Push + $this->enviarPush([['tipo' => $tipo_dest, 'id' => $id_dest]], $titulo, $mensaje, $url); + + return $notif; + } + + /** + * Enviar la misma notificación a múltiples destinatarios. + * $destinatarios = [['tipo' => 'jugador', 'id' => 'XXX'], ...] + */ + public function enviarMasivo(array $destinatarios, string $tipo, string $titulo, string $mensaje, ?string $url = null): int + { + $rows = []; + $ahora = now()->toDateTimeString(); + + foreach ($destinatarios as $dest) { + $rows[] = [ + 'tipo_destinatario' => $dest['tipo'], + 'id_destinatario' => (string) $dest['id'], + 'tipo' => $tipo, + 'titulo' => $titulo, + 'mensaje' => $mensaje, + 'url_accion' => $url, + 'leida' => false, + 'enviada_email' => false, + 'creada_en' => $ahora, + ]; + } + + if (empty($rows)) return 0; + + // Insert en chunks para no sobrecargar + foreach (array_chunk($rows, 500) as $chunk) { + Notificacion::insert($chunk); + } + + // Intentar envío Push masivo + $this->enviarPush($destinatarios, $titulo, $mensaje, $url); + + return count($rows); + } + + /** + * Lógica centralizada para enviar Web Push Notifications + */ + private function enviarPush(array $destinatarios, string $titulo, string $mensaje, ?string $url = null): void + { + $vPublic = env('VAPID_PUBLIC_KEY'); + $vPrivate = env('VAPID_PRIVATE_KEY'); + + if (!$vPublic || !$vPrivate) return; + + // Buscar todas las suscripciones de estos usuarios + $subsFound = PushSubscription::where(function($query) use ($destinatarios) { + foreach ($destinatarios as $d) { + $query->orWhere(function($q) use ($d) { + $q->where('tipo_usuario', $d['tipo']) + ->where('id_usuario', (string)$d['id']); + }); + } + })->get(); + + if ($subsFound->isEmpty()) return; + + try { + $auth = [ + 'VAPID' => [ + 'subject' => env('APP_URL', 'http://localhost'), + 'publicKey' => $vPublic, + 'privateKey' => $vPrivate, + ], + ]; + + $webPush = new WebPush($auth); + $payload = json_encode([ + 'title' => $titulo, + 'body' => $mensaje, + 'url' => $url ?: '/', + ]); + + foreach ($subsFound as $sub) { + $webPush->queueNotification( + Subscription::create([ + 'endpoint' => $sub->endpoint, + 'publicKey' => $sub->p256dh, + 'authToken' => $sub->auth, + ]), + $payload + ); + } + + foreach ($webPush->flush() as $report) { + if (!$report->isSuccess()) { + // Si falló (ej: suscripción expirada), la borramos para no reintentar + if ($report->isSubscriptionExpired()) { + $endpoint = $report->getEndpoint(); + PushSubscription::where('endpoint', $endpoint)->delete(); + } + } + } + } catch (\Exception $e) { + Log::error("Error enviando Web Push: " . $e->getMessage()); + } + } + + /** + * Obtener notificaciones no leídas de un usuario. + */ + public function obtenerNoLeidas(string $tipo, string|int $id): Collection + { + return Notificacion::paraUsuario($tipo, $id) + ->noLeidas() + ->orderByDesc('creada_en') + ->limit(50) + ->get(); + } + + /** + * Obtener todas las notificaciones de un usuario (paginadas). + */ + public function obtenerTodas(string $tipo, string|int $id, int $perPage = 20) + { + return Notificacion::paraUsuario($tipo, $id) + ->orderByDesc('creada_en') + ->paginate($perPage); + } + + /** + * Contar notificaciones no leídas. + */ + public function contarNoLeidas(string $tipo, string|int $id): int + { + return Notificacion::paraUsuario($tipo, $id)->noLeidas()->count(); + } + + /** + * Marcar una notificación como leída (verificando pertenencia). + */ + public function marcarLeida(int $id_notif, string $tipo, string|int $id_dest): bool + { + return (bool) Notificacion::paraUsuario($tipo, $id_dest) + ->where('id', $id_notif) + ->update(['leida' => true]); + } + + /** + * Marcar todas como leídas para un usuario. + */ + public function marcarTodasLeidas(string $tipo, string|int $id_dest): int + { + return Notificacion::paraUsuario($tipo, $id_dest) + ->noLeidas() + ->update(['leida' => true]); + } + + /** + * Eliminar una notificación (verificando pertenencia). + */ + public function eliminar(int $id_notif, string $tipo, string|int $id_dest): bool + { + return (bool) Notificacion::paraUsuario($tipo, $id_dest) + ->where('id', $id_notif) + ->delete(); + } + + /** + * Eliminar todas las notificaciones de un usuario. + */ + public function eliminarTodas(string $tipo, string|int $id_dest): int + { + return Notificacion::paraUsuario($tipo, $id_dest)->delete(); + } +} + diff --git a/app/Services/TournamentService.php b/app/Services/TournamentService.php new file mode 100644 index 0000000..cf42cb9 --- /dev/null +++ b/app/Services/TournamentService.php @@ -0,0 +1,149 @@ +findOrFail($idTorneo); + $stats = []; + + foreach ($torneo->equipos as $equipo) { + $groupName = $equipo->pivot->grupo ?: ($equipo->categoria . ' ' . $equipo->division); + + $stats[$groupName][$equipo->id_equipo] = [ + 'id' => $equipo->id_equipo, + 'nombre' => $equipo->club->nombre ?? 'Equipo', + 'logo' => $equipo->club->imagen ?? null, + 'categoria' => $equipo->categoria, + 'pj' => 0, + 'pg' => 0, + 'pp' => 0, + 'tf' => 0, + 'tc' => 0, + 'pts' => 0, + ]; + } + + $query = Evento::where('id_torneo', $idTorneo) + ->whereNotNull('marcador_local') + ->whereNotNull('marcador_visitante'); + + if ($onlyRegular) { + $query->where('fase', Evento::FASE_REGULAR); + } + + $matches = $query->get(); + + foreach ($matches as $m) { + $localGroup = null; + $visitGroup = null; + + foreach ($stats as $group => $teams) { + if (isset($teams[$m->id_equipo_local])) $localGroup = $group; + if (isset($teams[$m->id_equipo_visitante])) $visitGroup = $group; + } + + if (!$localGroup || !$visitGroup) continue; + + $stats[$localGroup][$m->id_equipo_local]['pj']++; + $stats[$localGroup][$m->id_equipo_local]['tf'] += $m->marcador_local; + $stats[$localGroup][$m->id_equipo_local]['tc'] += $m->marcador_visitante; + + $stats[$visitGroup][$m->id_equipo_visitante]['pj']++; + $stats[$visitGroup][$m->id_equipo_visitante]['tf'] += $m->marcador_visitante; + $stats[$visitGroup][$m->id_equipo_visitante]['tc'] += $m->marcador_local; + + if ($m->marcador_local > $m->marcador_visitante) { + $stats[$localGroup][$m->id_equipo_local]['pg']++; + $stats[$localGroup][$m->id_equipo_local]['pts'] += 2; + $stats[$visitGroup][$m->id_equipo_visitante]['pp']++; + $stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 1; + } elseif ($m->marcador_visitante > $m->marcador_local) { + $stats[$visitGroup][$m->id_equipo_visitante]['pg']++; + $stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 2; + $stats[$localGroup][$m->id_equipo_local]['pp']++; + $stats[$localGroup][$m->id_equipo_local]['pts'] += 1; + } else { + $stats[$localGroup][$m->id_equipo_local]['pts'] += 1; + $stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 1; + } + } + + foreach ($stats as $group => &$teams) { + usort($teams, function($a, $b) { + if ($b['pts'] !== $a['pts']) return $b['pts'] - $a['pts']; + $diffA = $a['tf'] - $a['tc']; + $diffB = $b['tf'] - $b['tc']; + if ($diffB !== $diffA) return $diffB - $diffA; + return $b['tf'] - $a['tf']; + }); + } + + return $stats; + } + + public function getPlayoffBrackets(int $idTorneo): array + { + $playoffs = Evento::where('id_torneo', $idTorneo) + ->where('fase', '>', Evento::FASE_REGULAR) + ->with(['equipoLocal.club', 'equipoVisitante.club']) + ->orderBy('fase') + ->orderBy('numero_partido_bracket') + ->orderBy('fecha_evento') + ->get(); + + $bracket = [ + Evento::FASE_CUARTOS => collect(), + Evento::FASE_SEMIS => collect(), + Evento::FASE_FINAL => collect(), + ]; + + // Agrupar por fase y número de llave (bracket) + $grouped = $playoffs->groupBy(function($item) { + return $item->fase . '-' . $item->numero_partido_bracket; + }); + + foreach ($grouped as $key => $matches) { + $first = $matches->first(); + $fase = $first->fase; + $nro = $first->numero_partido_bracket; + + $winsLocal = 0; + $winsVisit = 0; + $finished = 0; + + foreach ($matches as $m) { + if ($m->marcador_local !== null && $m->marcador_visitante !== null) { + $finished++; + if ($m->marcador_local > $m->marcador_visitante) $winsLocal++; + elseif ($m->marcador_visitante > $m->marcador_local) $winsVisit++; + } + } + + $bracket[$fase][$nro] = [ + 'matches' => $matches, + 'wins_local' => $winsLocal, + 'wins_visitante' => $winsVisit, + 'total_partidos' => $matches->count(), + 'terminados' => $finished, + 'equipo_local' => $first->equipoLocal, + 'equipo_visitante' => $first->equipoVisitante, + ]; + } + + return $bracket; + } +}