From 8fc619f9e7e07ef9a667964b072ba59ab800025e Mon Sep 17 00:00:00 2001
From: Laucha1312
" . 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
` al final del archivo y agregar el include justo antes: + +```html + @include('components.genius-chat') + +``` + +Si ya hay scripts de Bootstrap u otros justo antes de ``, agregar el include después de ellos pero antes de ``. + +- [ ] **Paso 3: Agregar CSRF meta tag si no existe** + +En `resources/views/layouts/app.blade.php`, dentro de `
`, verificar que existe: +```html + +``` + +Si no existe, agregarlo debajo de ``. + +- [ ] **Paso 4: Verificar visualmente** + +```bash +php artisan serve +``` + +Abrir `http://localhost:8000` y verificar: +- El botón rojo con icono `bi-stars` aparece en la esquina inferior derecha +- Click abre el panel +- El campo de texto acepta input +- Cerrando y abriendo el panel no pierde mensajes de la sesión + +- [ ] **Paso 5: Commit final** + +```bash +git add resources/views/components/genius-chat.blade.php resources/views/layouts/app.blade.php +git commit -m "feat: add genius-chat bubble component and wire into layout" +``` + +--- + +## Verificación Final + +- [ ] **Ejecutar todos los tests** + +```bash +php artisan test +``` + +Resultado esperado: todos los tests del proyecto pasan (mínimo los 13 nuevos). + +- [ ] **Test de smoke manual (público)** + +1. Abrir el sitio sin loguearse +2. Clickear el botón del agente +3. Escribir: "¿Dónde veo mis QRs?" +4. Verificar que responde en español usando el manual + +- [ ] **Test de smoke manual (admin)** + +1. Loguearse como admin +2. Clickear el botón del agente +3. Escribir: "Listame los equipos del torneo 1" +4. Verificar que la tool se ejecuta y retorna los equipos + +- [ ] **Commit de cierre** + +```bash +git add . +git commit -m "feat: OnAPB Genius Agent — complete implementation" +``` + +--- + +## Comandos de Deploy en Hostinger + +```bash +# 1. Subir archivos y correr en SSH: +composer install --no-dev --optimize-autoloader +php artisan migrate --force +php artisan config:cache +php artisan route:cache +php artisan view:cache + +# 2. Configurar cron en hPanel de Hostinger (una sola vez): +# Reemplazar TU_USUARIO con tu usuario de Hostinger: +# 0 3 * * * cd /home/TU_USUARIO/public_html && php artisan agent:purge-threads >> /dev/null 2>&1 + +# 3. Verificar que la API key está en el .env de producción: +# GEMINI_API_KEY=tu_api_key_aqui +``` diff --git a/docs/superpowers/specs/2026-04-09-genius-agent-design.md b/docs/superpowers/specs/2026-04-09-genius-agent-design.md new file mode 100644 index 0000000..e25721b --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-genius-agent-design.md @@ -0,0 +1,244 @@ +# OnAPB Genius Agent — Spec de Diseño + +**Fecha:** 2026-04-09 +**Rama:** `feature/genius-agent` +**Stack:** Laravel 12, Prism PHP (prism-php/prism), Google Gemini 1.5 Flash, MySQL, Alpine.js + +--- + +## 1. Objetivo + +Implementar un agente de IA conversacional ("OnAPB Genius") integrado en onapb.com con dos modos de operación según el rol del usuario: + +- **Público** (no logueado, aficionado, jugador): asistente de navegación y consultas usando el `MANUAL_USUARIO.md` como contexto RAG simplificado. Sin tools. Sin persistencia entre sesiones. +- **Admin** (SuperAdmin rol=1 / GeneralAdmin rol=2): automatización de tareas mediante function calling (Tools). Historial persistente en MySQL, auto-purgado a los 30 días. + +--- + +## 2. Decisiones de Arquitectura + +| Decisión | Elección | Razón | +|---|---|---| +| SDK de AI | `prism-php/prism` | Soporte probado de Gemini 1.5 Flash + tools. Más estable que `laravel/ai` (nuevo). | +| Modelo | `gemini-1.5-flash` | Velocidad (2–5s), costo bajo, function calling. | +| Streaming | No — JSON completo | Hosting compartido Hostinger con límites PHP desconocidos. Evita problemas de output buffer. | +| Memoria admin | MySQL (`agent_threads`) | Sin Redis. JSON column para mensajes. Purge automático 30 días. | +| Memoria público | Session PHP | Stateless entre sesiones. Simple, sin overhead de BD. | +| Tools | Solo si admin_logged_in | Validado en `GeniusAgentService`, no en Gemini. | + +--- + +## 3. Estructura de Archivos + +``` +app/ +├── AI/ +│ ├── Tools/ +│ │ ├── CrearPartidoTool.php +│ │ ├── CargarPuntajeTool.php +│ │ ├── RedactarNoticiaTool.php +│ │ ├── ListarEquiposTool.php +│ │ └── ListarEventosTool.php +│ └── Prompts/ +│ ├── SystemPromptAdmin.php +│ └── SystemPromptPublic.php +├── Services/ +│ └── GeniusAgentService.php +├── Http/Controllers/ +│ └── GeniusAgentController.php +├── Models/ +│ └── AgentThread.php +└── Console/Commands/ + └── PurgeAgentThreads.php + +database/migrations/ +└── xxxx_create_agent_threads_table.php + +resources/views/components/ +└── genius-chat.blade.php + +routes/web.php +└── POST /agent/chat (throttle: 20/min por IP) +``` + +--- + +## 4. Schema de Base de Datos + +```sql +CREATE TABLE agent_threads ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + thread_id VARCHAR(36) NOT NULL UNIQUE, -- UUID generado en frontend + admin_id INT NOT NULL, -- session('admin_id') + messages JSON NOT NULL, -- array [{role, content}] + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL -- created_at + 30 días +); +``` + +--- + +## 5. Tools Disponibles (Admin) + +Cada tool implementa la interfaz Prism `Tool`. El agent loop permite que Gemini llame la misma tool N veces para operaciones batch (ej. cargar puntajes de todos los partidos de una jornada). + +| Tool | Parámetros | Acción en BD | +|---|---|---| +| `CrearPartidoTool` | `id_equipo_local`, `id_equipo_visitante`, `fecha_evento`, `hora_inicio`, `hora_fin`, `sede`, `id_torneo` | `Evento::create(...)` | +| `CargarPuntajeTool` | `id_evento`, `marcador_local`, `marcador_visitante` | `Evento::find()->update(...)` | +| `RedactarNoticiaTool` | `titulo`, `contenido`, `id_torneo?`, `categoria?` | `Noticia::create(...)` | +| `ListarEquiposTool` | `id_torneo?`, `id_club?`, `grupo?` | `Equipo::query()` con join a pivot `torneo_equipo` filtrando por `grupo` (solo lectura) | +| `ListarEventosTool` | `fecha_desde?`, `fecha_hasta?`, `id_torneo?` | `Evento::query()->get()` (solo lectura) | + +**Nota sobre `grupo`:** No es un modelo separado. Es una columna en la tabla pivot `torneo_equipo` (relación `Torneo::equipos()->withPivot('grupo')`). `ListarEquiposTool` filtra con `wherePivot('grupo', $grupo)`. + +**Agregar una nueva tool en el futuro:** +1. Crear `app/AI/Tools/NuevaTool.php` +2. Registrarla en `GeniusAgentService::getAdminTools()` + +--- + +## 6. Flujo de Datos + +### Usuario público +``` +POST /agent/chat { message } +→ session()->get('agent_messages', []) +→ GeniusAgentService::chatPublic(message, history) + → System prompt: navegación + MANUAL_USUARIO.md completo como contexto (el archivo es pequeño; si crece >50KB usar solo las primeras 200 líneas) + → Prism → Gemini (sin tools) + → Respuesta de texto +→ session()->put('agent_messages', [...]) +→ return JSON { reply } +``` + +### Admin +``` +POST /agent/chat { message, thread_id? } +→ AgentThread::findOrCreate(thread_id, admin_id) +→ GeniusAgentService::chatAdmin(message, thread) + → System prompt: automatización de tareas OnAPB + → Prism → Gemini con tools + → [si Gemini llama tool] → Tool::handle() → resultado + → [Gemini puede llamar N tools] → agent loop + → Respuesta final de texto +→ thread->appendMessages([...]) +→ thread->save() +→ return JSON { reply, thread_id } +``` + +--- + +## 7. Seguridad + +- **Tools bloqueadas a no-admins:** `GeniusAgentService` valida `session('admin_logged_in')` antes de incluir tools. +- **Rate limiting:** `throttle:20,1` en la ruta `/agent/chat`. +- **Validación de input:** `message` requerido, string, máx. 1000 caracteres. +- **Aislamiento de threads:** cada thread valida `admin_id = session('admin_id')`. No hay acceso cruzado entre admins. +- **API key:** solo en `.env` (`GEMINI_API_KEY`). Nunca en código ni en BD. + +--- + +## 8. Manejo de Errores + +| Caso | Comportamiento | +|---|---| +| Gemini timeout / 5xx | `catch RequestException` → JSON `{ error: "El agente no responde, reintentá en un momento." }` | +| Tool falla (ej. evento no existe) | Tool retorna `{ error: "..." }` → Gemini lo incorpora en su respuesta | +| Thread expirado / no encontrado | Se crea un nuevo thread automáticamente | +| API key inválida / quota excedida | Log en Laravel + respuesta genérica (sin exponer detalles de la API) | +| Timeout de Hostinger | `set_time_limit(120)` al inicio del controller action | + +--- + +## 9. Purge de Threads + +Comando `php artisan agent:purge-threads` que elimina `agent_threads` donde `expires_at < NOW()`. + +**Schedule:** Se registra en `routes/console.php` para ejecutarse diariamente. En Hostinger sin queue worker, se puede configurar como cron en hPanel: + +``` +# Reemplazar /home/TU_USUARIO/public_html con la ruta real de tu cuenta Hostinger +0 3 * * * cd /home/TU_USUARIO/public_html && php artisan agent:purge-threads >> /dev/null 2>&1 +``` + +--- + +## 10. Frontend — Chat Bubble + +Componente Blade `genius-chat.blade.php` incluido al final de `resources/views/layouts/app.blade.php`: + +- Botón flotante (bottom-right) con ícono de chat +- Panel slide-up con historial de mensajes de la sesión actual +- Alpine.js para estado local (open/closed, messages, loading spinner) +- Tailwind para estilos (sin dependencias adicionales) +- Rol-aware: el frontend no diferencia, la diferencia la hace el backend + +--- + +## 11. Comandos de Deploy + +### Instalación inicial (local y producción) + +```bash +# 1. Instalar Prism PHP +composer require prism-php/prism + +# 2. Publicar config de Prism (opcional) +php artisan vendor:publish --provider="EchoLabs\Prism\PrismServiceProvider" + +# 3. Agregar en .env +GEMINI_API_KEY=tu_api_key_aqui + +# 4. Ejecutar migration de agent_threads +php artisan migrate + +# 5. Registrar el comando de purge (se hace automáticamente con el código) +# Verificar que está en routes/console.php + +# 6. Limpiar cachés después del deploy +php artisan config:clear +php artisan cache:clear +php artisan view:clear +php artisan route:clear + +# 7. Re-cachear para producción +php artisan config:cache +php artisan route:cache +php artisan view:cache +``` + +### Comandos de mantenimiento + +```bash +# Purge manual de threads expirados +php artisan agent:purge-threads + +# Ver logs del agente +php artisan pail --filter="GeniusAgent" + +# Limpiar todos los threads (emergencia) +php artisan tinker --execute="App\Models\AgentThread::truncate();" +``` + +### Deploy en Hostinger (via SSH o File Manager) + +```bash +# Subir archivos nuevos y correr: +php artisan migrate --force +php artisan config:cache +php artisan route:cache +php artisan view:cache +``` + +--- + +## 12. Configuración `.env` requerida + +```env +GEMINI_API_KEY=tu_api_key_de_google_ai_studio + +# Opcional: ajustar timeout HTTP de Prism +PRISM_HTTP_TIMEOUT=30 +``` diff --git a/docs/superpowers/specs/2026-04-24-pagos-macro-design.md b/docs/superpowers/specs/2026-04-24-pagos-macro-design.md new file mode 100644 index 0000000..3758e8c --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-pagos-macro-design.md @@ -0,0 +1,376 @@ +# Diseño: Sistema de Pagos con Banco Macro + +**Fecha:** 2026-04-24 +**Estado:** Aprobado por el usuario — listo para plan de implementación +**Proyecto:** OnAPB v2 (Laravel / Hostinger Business) + +--- + +## Contexto + +OnAPB v2 es el sistema de gestión de la Asociación Paranaense de Básquet. Se busca implementar un sistema de cobros digitales integrado con Banco Macro (Macro Click de Pago — Botón Integrado), centralizado en la cuenta de la APB. + +- Framework: Laravel (PHP) +- Roles del sistema: super admin (role=1), admin de club (role=2), jugadores/aficionados (usuarios registrados), anónimos +- Existía una integración con MercadoPago que fue eliminada +- Las credenciales del Botón Integrado de Macro fueron solicitadas el 2026-04-23 y están pendientes de recibir +- Contacto Macro: Diego Dallanora — diegodallanora@macro.com.ar — +54 3794 15-0073 + +--- + +## Decisiones de diseño + +| Decisión | Resolución | +|---|---| +| Pasarela de pago | Macro Click de Pago — **Botón Integrado** | +| Quién recauda | **La APB centraliza** todos los pagos (un solo merchant Macro) | +| Entrega tienda | **Retiro físico en sede** (sin envíos) | +| Sanciones | Super admin las carga manualmente sobre un jugador | +| Modelo de datos | **Tabla polimórfica de pagos** (Opción A) | + +## Comisiones Macro (propuesta comercial vigente) + +| Medio | Comisión | Acreditación | +|---|---|---| +| Tarjeta de crédito | 3.05% | 18 días hábiles | +| Tarjeta de crédito en cuotas | 3.05% | 18 días hábiles | +| Tarjeta de débito | 3.00% | 1 día hábil | +| DEBIN | 3.00% | 1 día hábil | + +--- + +## Descomposición en sub-proyectos + +Implementar en este orden (cada uno depende del anterior): + +1. **Motor de pagos** — integración Macro base, modelo de datos, webhook +2. **Cobros institucionales** — inscripciones, multas/sanciones +3. **Tienda online** — catálogo, carrito, checkout + +--- + +## Sección 1 — Modelo de datos + +### Tablas nuevas + +#### `concepto_pagos` — plantillas configurables por el super admin +| Campo | Tipo | Notas | +|---|---|---| +| `id` | PK | | +| `nombre` | string | ej: "Inscripción Anual Jugador 2026" | +| `descripcion` | text | | +| `monto` | decimal(10,2) | | +| `tipo` | enum | `inscripcion_jugador`, `inscripcion_equipo`, `multa`, `tienda` | +| `temporada` | string nullable | ej: "2026" — para conceptos anuales | +| `activo` | boolean | default true | +| timestamps | | | + +#### `pagos` — registro de transacciones (polimórfico) +| Campo | Tipo | Notas | +|---|---|---| +| `id` | PK | | +| `concepto_pago_id` | FK → concepto_pagos | | +| `pagable_type` | string | App\Models\Jugador / Club / OrdenTienda / Sancion | +| `pagable_id` | unsignedBigInt | | +| `monto` | decimal(10,2) | snapshot al crear el pago | +| `estado` | enum | `pendiente`, `pagado`, `fallido`, `cancelado` | +| `macro_transaction_id` | string nullable | ID devuelto por Macro | +| `macro_payload` | json nullable | respuesta raw de Macro | +| `paid_at` | timestamp nullable | | +| `iniciado_por_type` | string nullable | quién inició (jugador, admin_user, null=anónimo) | +| `iniciado_por_id` | unsignedBigInt nullable | | +| timestamps | | | + +#### `sanciones` — registros disciplinarios +| Campo | Tipo | Notas | +|---|---|---| +| `id` | PK | | +| `jugador_id` | FK → jugadores | | +| `id_club` | FK → clubes | club al momento de la sanción | +| `motivo` | string | | +| `descripcion` | text nullable | | +| `fecha_sancion` | date | | +| `admin_id` | FK → admin_users | quién la cargó | +| timestamps | | | + +El pago de la sanción vive en `pagos` con `pagable_type = App\Models\Sancion`. + +#### `productos` — catálogo de la tienda +| Campo | Tipo | | +|---|---|---| +| `id` | PK | | +| `nombre` | string | | +| `descripcion` | text nullable | | +| `precio` | decimal(10,2) | | +| `stock` | unsignedInt | | +| `imagen` | string nullable | | +| `activo` | boolean | default true | +| timestamps | | | + +#### `ordenes_tienda` — órdenes de compra +| Campo | Tipo | Notas | +|---|---|---| +| `id` | PK | | +| `user_id` | FK nullable → users | null si comprador anónimo | +| `nombre_comprador` | string | capturado en checkout | +| `email_comprador` | string | para enviar comprobante | +| `estado` | enum | `pendiente_pago`, `pagado`, `listo_retiro`, `retirado`, `cancelado` | +| timestamps | | | + +#### `orden_items` — líneas de cada orden +| Campo | Tipo | Notas | +|---|---|---| +| `id` | PK | | +| `orden_id` | FK → ordenes_tienda | | +| `producto_id` | FK → productos | | +| `cantidad` | unsignedInt | | +| `precio_unitario` | decimal(10,2) | snapshot al momento de compra | + +### Relaciones Eloquent +``` +ConceptoPago hasMany Pago +Pago morphTo pagable (Jugador | Club | OrdenTienda | Sancion) +Sancion belongsTo Jugador, Club, AdminUser +OrdenTienda hasMany OrdenItem +OrdenItem belongsTo Producto +``` + +--- + +## Sección 2 — Flujo de pago con Macro (Botón Integrado) + +### Flujo estándar (aplica a todos los conceptos de pago) + +``` +Usuario decide pagar un concepto + ↓ +Sistema crea registro en `pagos` (estado = pendiente) + ↓ +GET /checkout/{pago} +Página con botón Macro embebido (monto + referencia interna precargados) + ↓ +Usuario interactúa con el formulario de Macro +(tarjeta crédito / débito / DEBIN) + ↓ + ┌──────────────────┬─────────────────┐ + ↓ ↓ ↓ + Pago exitoso Pago fallido Abandona + ↓ ↓ ↓ + Macro → /pagos/ Macro → /pagos/ Pago queda pendiente + exitoso fallido (expira por cron) + ↓ +POST /api/macro/webhook ← fuente de verdad + ↓ +Valida firma de Macro + ↓ +Actualiza pago: estado = pagado, paid_at = now(), macro_payload = {...} + ↓ +Dispara evento post-pago según pagable_type + ↓ +Envía email de comprobante al pagador +``` + +**Regla crítica:** El webhook es la fuente de verdad, no la redirección. Las páginas de éxito/fallo son solo UX. + +### Eventos post-pago por tipo + +| `pagable_type` | Acción tras confirmar pago | +|---|---| +| `Jugador` (inscripción) | Marcar jugador como inscripto en la temporada | +| `Club` (inscripción equipo) | Marcar equipo/club como habilitado para participar | +| `Sancion` | Marcar sanción como saldada | +| `OrdenTienda` | Estado → `pagado`; notificar al super admin | + +### Rutas nuevas + +``` +GET /checkout/{pago} → página con botón Macro embebido +GET /pagos/exitoso → pantalla de éxito (UX) +GET /pagos/fallido → pantalla de fallo (UX) +POST /api/macro/webhook → receptor del webhook (excluido de CSRF) +GET /tienda → catálogo público +GET /tienda/carrito → carrito de compras +POST /tienda/checkout → genera orden + pago, redirige a /checkout/{pago} +``` + +### Seguridad del webhook + +- Verificar firma de Macro antes de procesar (algoritmo a confirmar con Macro) +- El monto siempre se toma de `pagos.monto`, nunca del payload entrante +- Idempotente: si llega dos veces el mismo `macro_transaction_id`, no procesar dos veces +- Log de cada webhook recibido para auditoría + +--- + +## Sección 3 — Panel de administración + +### Super admin (role=1) — nuevas secciones + +**A. Conceptos de pago** +- Listado con filtros por tipo y estado (activo/inactivo) +- Crear / editar: nombre, descripción, monto, tipo, temporada, activo +- Desactivar un concepto no elimina ni afecta pagos ya realizados + +**B. Sanciones** +- Formulario: buscar jugador → seleccionar club → ingresar motivo, descripción, fecha → asociar concepto de pago tipo `multa` +- Listado con estado de pago (pendiente / saldada); filtros por club, jugador, estado, fecha + +**C. Tienda** +- CRUD de productos: nombre, descripción, precio, stock, imagen, activo +- Listado de órdenes con gestión de estado: + - `pagado` → "Marcar listo para retirar" → email automático al comprador + - `listo_retiro` → "Marcar como retirado" +- Filtros por estado y fecha + +**D. Transacciones / Recaudación** +- Tabla de todos los `pagos` con filtros: estado, tipo de concepto, rango de fechas +- Totales agrupados por tipo de concepto +- Exportar CSV para conciliación con la plataforma de Macro + +### Admin de club (role=2) — nuevas secciones + +**A. Sanciones del club** +- Lista de sanciones de los jugadores de su club +- Estado de cada sanción (pendiente / saldada) +- Botón para pagar una sanción pendiente (el club abona, no el jugador) + +**B. Inscripciones del club** +- Conceptos de inscripción de equipo disponibles para la temporada vigente +- Botón para pagar inscripción de equipo + +--- + +## Sección 4 — Vistas del usuario + +### Jugador registrado (`/panel-usuario`) + +Se agregan dos bloques al panel existente: + +**A. Mis pagos pendientes** +- Lista de cobros asignados al jugador: inscripción anual, sanciones +- Por cada uno: concepto, monto, estado, botón "Pagar ahora" +- Si ya está pagado: fecha + link al comprobante + +**B. Historial de pagos** +- Todos los pagos realizados (inscripciones, sanciones, compras en tienda) +- Filtro por estado y fecha + +### Tienda pública (`/tienda`) + +- Accesible desde la navbar para todos (registrados y anónimos) +- Catálogo: productos activos con stock > 0 +- Carrito en sesión (sin necesidad de cuenta) +- Checkout: formulario con nombre + email → pago con Macro +- Comprobante por email con información de retiro en sede +- Si el usuario tiene cuenta: la orden se vincula a su `user_id` y aparece en su historial + +### Invitación a registrarse (opcional, no obligatorio) +En el checkout anónimo se muestra: *"¿Tenés cuenta? Ingresá para guardar tu historial de compras."* + +--- + +## Sección 5 — Consideraciones técnicas y testing + +### Pendientes a confirmar con Macro al recibir credenciales + +- URL del endpoint para generar transacción / token de pago +- Formato exacto del webhook (campos, firma, algoritmo de verificación — probablemente HMAC-SHA256) +- Si existe **entorno sandbox** para pruebas (solicitarlo explícitamente) +- Si el botón es JS embebido, iframe, o redirect + +### Estructura de clases nuevas en Laravel + +``` +app/ + Services/ + MacroService.php ← toda la comunicación con Macro (stub hasta recibir credenciales) + PagoService.php ← crea pagos, dispara eventos post-pago + Events/ + PagoConfirmado.php + Listeners/ + EnviarComprobantePago.php + MarcarJugadorInscripto.php + MarcarSancionSaldada.php + ActualizarOrdenTienda.php + Jobs/ + CancelarPagosPendientesVencidos.php ← cron diario + Http/Controllers/ + CheckoutController.php + MacroWebhookController.php + TiendaController.php + Admin/ConceptoPagoController.php + Admin/SancionAdminController.php + Admin/ProductoController.php + Admin/OrdenTiendaController.php + Admin/RecaudacionController.php +``` + +### Variables de entorno necesarias + +```env +MACRO_MERCHANT_ID= +MACRO_API_KEY= +MACRO_SECRET= +MACRO_WEBHOOK_SECRET= +MACRO_ENV=sandbox # cambiar a "production" al salir a producción +MACRO_SUCCESS_URL="${APP_URL}/pagos/exitoso" +MACRO_FAILURE_URL="${APP_URL}/pagos/fallido" +MACRO_WEBHOOK_URL="${APP_URL}/api/macro/webhook" +``` + +### Plan de testing en dos etapas + +**Etapa 1 — Sin credenciales Macro (desarrollo inmediato)** +- Tests unitarios del modelo de datos: crear pagos, sanciones, órdenes +- Tests de eventos post-pago con `MacroService` mockeado +- Tests del webhook con payload simulado y firma fake +- Tests de las vistas admin (CRUD de conceptos, productos, sanciones) + +**Etapa 2 — Con Macro sandbox** +- Pruebas end-to-end con tarjeta de prueba en sandbox +- Verificar que el webhook llega y el estado se actualiza +- Verificar idempotencia (enviar webhook dos veces → mismo resultado) +- Verificar email de comprobante + +### Orden de implementación recomendado + +1. Migraciones + modelos (`ConceptoPago`, `Pago`, `Sancion`, `Producto`, `OrdenTienda`, `OrdenItem`) +2. `MacroService` con métodos stub +3. Panel admin: conceptos de pago → sanciones → tienda → reportes/recaudación +4. Panel usuario: pagos pendientes + historial +5. Tienda pública: catálogo + carrito + checkout +6. Webhook receptor + eventos post-pago + job de cancelación +7. Reemplazar stubs con implementación real de Macro al recibir credenciales + +--- + +## Inscripción de jugadores — detalle adicional ✅ + +### Dos tipos de inscripción por temporada + +La temporada abarca Torneo Apertura + Clausura. Hay jugadores que se incorporan a mitad de año y solo deben pagar una inscripción reducida. Por lo tanto, el super admin crea **dos conceptos** por temporada: + +| Concepto | Tipo | Ejemplo precio | Aplica a | +|---|---|---|---| +| Inscripción Anual Completa 2026 | `inscripcion_jugador` | $X | Jugadores desde Apertura | +| Inscripción Reducida 2026 (Clausura) | `inscripcion_jugador` | $Y | Jugadores que ingresan a mitad de año | + +### Generación masiva de deudas + +En el panel admin (super admin), sección **Conceptos de pago**, se agrega una acción: + +**"Generar deuda masiva"** +1. Super admin selecciona el concepto (ej: "Inscripción Anual Completa 2026") +2. Sistema muestra todos los jugadores activos que **no tienen ya ese concepto generado** para esa temporada +3. Super admin puede desmarcar jugadores individuales (ej: los que van a recibir la inscripción reducida) +4. Confirma → sistema crea un registro `pagos` (estado=pendiente) por cada jugador seleccionado +5. Cada jugador ve la deuda en su panel usuario + +Para los jugadores que se incorporan a mitad de año, el admin repite el proceso con el concepto "Inscripción Reducida". + +**Regla de negocio:** Un jugador no puede tener dos pagos pendientes del mismo concepto simultáneamente (se valida antes de generar). + +## Preguntas abiertas + +- ¿Tiene Macro un entorno sandbox para desarrollo? (pendiente confirmar con Diego Dallanora) +- ¿Qué campos exactos envía Macro en el webhook? (se confirma al recibir credenciales)