From 1a8c01ae45ddae7c24ee04a8ce4ea2118ba90a3c 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)
diff --git a/lang/es/validation.php b/lang/es/validation.php
new file mode 100644
index 0000000..03a0da3
--- /dev/null
+++ b/lang/es/validation.php
@@ -0,0 +1,173 @@
+ 'El campo :attribute debe ser aceptado.',
+ 'accepted_if' => 'El campo :attribute debe ser aceptado cuando :other es :value.',
+ 'active_url' => 'El campo :attribute no es una URL válida.',
+ 'after' => 'El campo :attribute debe ser una fecha posterior a :date.',
+ 'after_or_equal' => 'El campo :attribute debe ser una fecha posterior o igual a :date.',
+ 'alpha' => 'El campo :attribute solo puede contener letras.',
+ 'alpha_dash' => 'El campo :attribute solo puede contener letras, números, guiones y guiones bajos.',
+ 'alpha_num' => 'El campo :attribute solo puede contener letras y números.',
+ 'array' => 'El campo :attribute debe ser un array.',
+ 'ascii' => 'El campo :attribute solo puede contener caracteres alfanuméricos de un byte y símbolos.',
+ 'before' => 'El campo :attribute debe ser una fecha anterior a :date.',
+ 'before_or_equal' => 'El campo :attribute debe ser una fecha anterior o igual a :date.',
+ 'between' => [
+ 'array' => 'El campo :attribute debe tener entre :min y :max elementos.',
+ 'file' => 'El archivo :attribute debe pesar entre :min y :max kilobytes.',
+ 'numeric' => 'El campo :attribute debe estar entre :min y :max.',
+ 'string' => 'El campo :attribute debe tener entre :min y :max caracteres.',
+ ],
+ 'boolean' => 'El campo :attribute debe ser verdadero o falso.',
+ 'can' => 'El campo :attribute contiene un valor no autorizado.',
+ 'confirmed' => 'La confirmación de :attribute no coincide.',
+ 'current_password' => 'La contraseña es incorrecta.',
+ 'date' => 'El campo :attribute no es una fecha válida.',
+ 'date_equals' => 'El campo :attribute debe ser una fecha igual a :date.',
+ 'date_format' => 'El campo :attribute no corresponde con el formato :format.',
+ 'decimal' => 'El campo :attribute debe tener :decimal decimales.',
+ 'declined' => 'El campo :attribute debe ser rechazado.',
+ 'declined_if' => 'El campo :attribute debe ser rechazado cuando :other es :value.',
+ 'different' => 'El campo :attribute y :other deben ser diferentes.',
+ 'digits' => 'El campo :attribute debe tener :digits dígitos.',
+ 'digits_between' => 'El campo :attribute debe tener entre :min y :max dígitos.',
+ 'dimensions' => 'Las dimensiones de la imagen :attribute no son válidas.',
+ 'distinct' => 'El campo :attribute tiene un valor duplicado.',
+ 'doesnt_end_with' => 'El campo :attribute no debe terminar con uno de los siguientes valores: :values.',
+ 'doesnt_start_with' => 'El campo :attribute no debe comenzar con uno de los siguientes valores: :values.',
+ 'email' => 'El campo :attribute debe ser una dirección de correo válida.',
+ 'ends_with' => 'El campo :attribute debe terminar con uno de los siguientes valores: :values.',
+ 'enum' => 'El valor seleccionado para :attribute no es válido.',
+ 'exists' => 'El campo :attribute seleccionado no existe.',
+ 'extensions' => 'El campo :attribute debe tener una de las extensiones: :values.',
+ 'file' => 'El campo :attribute debe ser un archivo.',
+ 'filled' => 'El campo :attribute no puede estar vacío.',
+ 'gt' => [
+ 'array' => 'El campo :attribute debe tener más de :value elementos.',
+ 'file' => 'El archivo :attribute debe ser mayor a :value kilobytes.',
+ 'numeric' => 'El campo :attribute debe ser mayor que :value.',
+ 'string' => 'El campo :attribute debe tener más de :value caracteres.',
+ ],
+ 'gte' => [
+ 'array' => 'El campo :attribute debe tener :value elementos o más.',
+ 'file' => 'El archivo :attribute debe ser mayor o igual a :value kilobytes.',
+ 'numeric' => 'El campo :attribute debe ser mayor o igual que :value.',
+ 'string' => 'El campo :attribute debe tener :value caracteres o más.',
+ ],
+ 'hex_color' => 'El campo :attribute debe ser un color hexadecimal válido.',
+ 'image' => 'El campo :attribute debe ser una imagen.',
+ 'in' => 'El valor seleccionado para :attribute no es válido.',
+ 'in_array' => 'El campo :attribute no existe en :other.',
+ 'integer' => 'El campo :attribute debe ser un número entero.',
+ 'ip' => 'El campo :attribute debe ser una dirección IP válida.',
+ 'ipv4' => 'El campo :attribute debe ser una dirección IPv4 válida.',
+ 'ipv6' => 'El campo :attribute debe ser una dirección IPv6 válida.',
+ 'json' => 'El campo :attribute debe ser una cadena JSON válida.',
+ 'list' => 'El campo :attribute debe ser una lista.',
+ 'lowercase' => 'El campo :attribute debe estar en minúsculas.',
+ 'lt' => [
+ 'array' => 'El campo :attribute debe tener menos de :value elementos.',
+ 'file' => 'El archivo :attribute debe ser menor a :value kilobytes.',
+ 'numeric' => 'El campo :attribute debe ser menor que :value.',
+ 'string' => 'El campo :attribute debe tener menos de :value caracteres.',
+ ],
+ 'lte' => [
+ 'array' => 'El campo :attribute no debe tener más de :value elementos.',
+ 'file' => 'El archivo :attribute debe ser menor o igual a :value kilobytes.',
+ 'numeric' => 'El campo :attribute debe ser menor o igual que :value.',
+ 'string' => 'El campo :attribute debe tener :value caracteres o menos.',
+ ],
+ 'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.',
+ 'max' => [
+ 'array' => 'El campo :attribute no puede tener más de :max elementos.',
+ 'file' => 'El archivo :attribute no puede ser mayor a :max kilobytes.',
+ 'numeric' => 'El campo :attribute no puede ser mayor a :max.',
+ 'string' => 'El campo :attribute no puede tener más de :max caracteres.',
+ ],
+ 'max_digits' => 'El campo :attribute no puede tener más de :max dígitos.',
+ 'mimes' => 'El campo :attribute debe ser un archivo de tipo: :values.',
+ 'mimetypes' => 'El campo :attribute debe ser un archivo de tipo: :values.',
+ 'min' => [
+ 'array' => 'El campo :attribute debe tener al menos :min elementos.',
+ 'file' => 'El archivo :attribute debe pesar al menos :min kilobytes.',
+ 'numeric' => 'El campo :attribute debe ser al menos :min.',
+ 'string' => 'El campo :attribute debe tener al menos :min caracteres.',
+ ],
+ 'min_digits' => 'El campo :attribute debe tener al menos :min dígitos.',
+ 'missing' => 'El campo :attribute debe estar ausente.',
+ 'missing_if' => 'El campo :attribute debe estar ausente cuando :other es :value.',
+ 'missing_unless' => 'El campo :attribute debe estar ausente a menos que :other sea :value.',
+ 'missing_with' => 'El campo :attribute debe estar ausente cuando :values está presente.',
+ 'missing_with_all' => 'El campo :attribute debe estar ausente cuando :values están presentes.',
+ 'multiple_of' => 'El campo :attribute debe ser un múltiplo de :value.',
+ 'not_in' => 'El valor seleccionado para :attribute no es válido.',
+ 'not_regex' => 'El formato del campo :attribute no es válido.',
+ 'numeric' => 'El campo :attribute debe ser un número.',
+ 'password' => [
+ 'letters' => 'El campo :attribute debe contener al menos una letra.',
+ 'mixed' => 'El campo :attribute debe contener al menos una letra mayúscula y una minúscula.',
+ 'numbers' => 'El campo :attribute debe contener al menos un número.',
+ 'symbols' => 'El campo :attribute debe contener al menos un símbolo.',
+ 'uncompromised' => 'El :attribute dado ha aparecido en una filtración de datos. Por favor, elija un :attribute diferente.',
+ ],
+ 'present' => 'El campo :attribute debe estar presente.',
+ 'present_if' => 'El campo :attribute debe estar presente cuando :other es :value.',
+ 'present_unless' => 'El campo :attribute debe estar presente a menos que :other sea :value.',
+ 'present_with' => 'El campo :attribute debe estar presente cuando :values esté presente.',
+ 'present_with_all' => 'El campo :attribute debe estar presente cuando :values estén presentes.',
+ 'prohibited' => 'El campo :attribute está prohibido.',
+ 'prohibited_if' => 'El campo :attribute está prohibido cuando :other es :value.',
+ 'prohibited_unless' => 'El campo :attribute está prohibido a menos que :other esté en :values.',
+ 'prohibits' => 'El campo :attribute prohíbe que :other esté presente.',
+ 'regex' => 'El formato del campo :attribute no es válido.',
+ 'required' => 'El campo :attribute es obligatorio.',
+ 'required_array_keys' => 'El campo :attribute debe contener entradas para: :values.',
+ 'required_if' => 'El campo :attribute es obligatorio cuando :other es :value.',
+ 'required_if_accepted' => 'El campo :attribute es obligatorio cuando :other es aceptado.',
+ 'required_unless' => 'El campo :attribute es obligatorio a menos que :other esté en :values.',
+ 'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
+ 'required_with_all' => 'El campo :attribute es obligatorio cuando :values están presentes.',
+ 'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
+ 'required_without_all' => 'El campo :attribute es obligatorio cuando ninguno de los valores :values está presente.',
+ 'same' => 'El campo :attribute y :other deben coincidir.',
+ 'size' => [
+ 'array' => 'El campo :attribute debe contener :size elementos.',
+ 'file' => 'El archivo :attribute debe pesar :size kilobytes.',
+ 'numeric' => 'El campo :attribute debe ser :size.',
+ 'string' => 'El campo :attribute debe tener :size caracteres.',
+ ],
+ 'starts_with' => 'El campo :attribute debe comenzar con uno de los siguientes valores: :values.',
+ 'string' => 'El campo :attribute debe ser una cadena de texto.',
+ 'timezone' => 'El campo :attribute debe ser una zona horaria válida.',
+ 'unique' => 'El valor del campo :attribute ya está en uso.',
+ 'uploaded' => 'El archivo :attribute no pudo ser subido.',
+ 'uppercase' => 'El campo :attribute debe estar en mayúsculas.',
+ 'url' => 'El campo :attribute debe ser una URL válida.',
+ 'ulid' => 'El campo :attribute debe ser un ULID válido.',
+ 'uuid' => 'El campo :attribute debe ser un UUID válido.',
+
+ 'attributes' => [
+ 'nombre' => 'nombre',
+ 'imagen' => 'imagen',
+ 'url' => 'URL',
+ 'activo' => 'estado activo',
+ 'orden' => 'orden',
+ 'titulo' => 'título',
+ 'subtitulo' => 'subtítulo',
+ 'boton_texto' => 'texto del botón',
+ 'boton_enlace' => 'enlace del botón',
+ 'email' => 'correo electrónico',
+ 'password' => 'contraseña',
+ 'nombre_evento' => 'nombre del evento',
+ 'fecha_evento' => 'fecha del evento',
+ 'hora_inicio' => 'hora de inicio',
+ 'hora_fin' => 'hora de fin',
+ 'sede' => 'sede',
+ 'documento' => 'documento',
+ 'apellido' => 'apellido',
+ 'fecha_nacimiento' => 'fecha de nacimiento',
+ ],
+
+];
diff --git a/lang/vendor/backup/ar/notifications.php b/lang/vendor/backup/ar/notifications.php
new file mode 100644
index 0000000..48bc709
--- /dev/null
+++ b/lang/vendor/backup/ar/notifications.php
@@ -0,0 +1,45 @@
+ 'رسالة استثناء: :message',
+ 'exception_trace' => 'تتبع الإستثناء: :trace',
+ 'exception_message_title' => 'رسالة استثناء',
+ 'exception_trace_title' => 'تتبع الإستثناء',
+
+ 'backup_failed_subject' => 'أخفق النسخ الاحتياطي لل :application_name',
+ 'backup_failed_body' => 'مهم: حدث خطأ أثناء النسخ الاحتياطي :application_name',
+
+ 'backup_successful_subject' => 'نسخ احتياطي جديد ناجح ل :application_name',
+ 'backup_successful_subject_title' => 'نجاح النسخ الاحتياطي الجديد!',
+ 'backup_successful_body' => 'أخبار عظيمة، نسخة احتياطية جديدة ل :application_name تم إنشاؤها بنجاح على القرص المسمى :disk_name.',
+
+ 'cleanup_failed_subject' => 'فشل تنظيف النسخ الاحتياطي للتطبيق :application_name .',
+ 'cleanup_failed_body' => 'حدث خطأ أثناء تنظيف النسخ الاحتياطية ل :application_name',
+
+ 'cleanup_successful_subject' => 'تنظيف النسخ الاحتياطية ل :application_name تمت بنجاح',
+ 'cleanup_successful_subject_title' => 'تنظيف النسخ الاحتياطية تم بنجاح!',
+ 'cleanup_successful_body' => 'تنظيف النسخ الاحتياطية ل :application_name على القرص المسمى :disk_name تم بنجاح.',
+
+ 'healthy_backup_found_subject' => 'النسخ الاحتياطية ل :application_name على القرص :disk_name صحية',
+ 'healthy_backup_found_subject_title' => 'النسخ الاحتياطية ل :application_name صحية',
+ 'healthy_backup_found_body' => 'تعتبر النسخ الاحتياطية ل :application_name صحية. عمل جيد!',
+
+ 'unhealthy_backup_found_subject' => 'مهم: النسخ الاحتياطية ل :application_name غير صحية',
+ 'unhealthy_backup_found_subject_title' => 'مهم: النسخ الاحتياطية ل :application_name غير صحية. :problem',
+ 'unhealthy_backup_found_body' => 'النسخ الاحتياطية ل :application_name على القرص :disk_name غير صحية.',
+ 'unhealthy_backup_found_not_reachable' => 'لا يمكن الوصول إلى وجهة النسخ الاحتياطي. :error',
+ 'unhealthy_backup_found_empty' => 'لا توجد نسخ احتياطية لهذا التطبيق على الإطلاق.',
+ 'unhealthy_backup_found_old' => 'تم إنشاء أحدث النسخ الاحتياطية في :date وتعتبر قديمة جدا.',
+ 'unhealthy_backup_found_unknown' => 'عذرا، لا يمكن تحديد سبب دقيق.',
+ 'unhealthy_backup_found_full' => 'النسخ الاحتياطية تستخدم الكثير من التخزين. الاستخدام الحالي هو :disk_usage وهو أعلى من الحد المسموح به من :disk_limit.',
+
+ 'no_backups_info' => 'لم يتم عمل نسخ احتياطية حتى الآن',
+ 'application_name' => 'اسم التطبيق',
+ 'backup_name' => 'اسم النسخ الاحتياطي',
+ 'disk' => 'القرص',
+ 'newest_backup_size' => 'أحدث حجم للنسخ الاحتياطي',
+ 'number_of_backups' => 'عدد النسخ الاحتياطية',
+ 'total_storage_used' => 'إجمالي مساحة التخزين المستخدمة',
+ 'newest_backup_date' => 'أحدث تاريخ النسخ الاحتياطي',
+ 'oldest_backup_date' => 'أقدم تاريخ نسخ احتياطي',
+];
diff --git a/lang/vendor/backup/bg/notifications.php b/lang/vendor/backup/bg/notifications.php
new file mode 100644
index 0000000..7c87d5b
--- /dev/null
+++ b/lang/vendor/backup/bg/notifications.php
@@ -0,0 +1,45 @@
+ 'Съобщение за изключение: :message',
+ 'exception_trace' => 'Проследяване на изключение: :trace',
+ 'exception_message_title' => 'Съобщение за изключение',
+ 'exception_trace_title' => 'Проследяване на изключение',
+
+ 'backup_failed_subject' => 'Неуспешно резервно копие на :application_name',
+ 'backup_failed_body' => 'Важно: Възникна грешка при архивиране на :application_name',
+
+ 'backup_successful_subject' => 'Успешно ново резервно копие на :application_name',
+ 'backup_successful_subject_title' => 'Успешно ново резервно копие!',
+ 'backup_successful_body' => 'Чудесни новини, ново резервно копие на :application_name беше успешно създадено на диска с име :disk_name.',
+
+ 'cleanup_failed_subject' => 'Почистването на резервните копия на :application_name не бе успешно.',
+ 'cleanup_failed_body' => 'Възникна грешка при почистването на резервните копия на :application_name',
+
+ 'cleanup_successful_subject' => 'Почистването на архивите на :application_name е успешно',
+ 'cleanup_successful_subject_title' => 'Почистването на резервните копия е успешно!',
+ 'cleanup_successful_body' => 'Почистването на резервни копия на :application_name на диска с име :disk_name беше успешно.',
+
+ 'healthy_backup_found_subject' => 'Резервните копия за :application_name на диск :disk_name са здрави',
+ 'healthy_backup_found_subject_title' => 'Резервните копия за :application_name са здрави',
+ 'healthy_backup_found_body' => 'Резервните копия за :application_name се считат за здрави. Добра работа!',
+
+ 'unhealthy_backup_found_subject' => 'Важно: Резервните копия за :application_name не са здрави',
+ 'unhealthy_backup_found_subject_title' => 'Важно: Резервните копия за :application_name не са здрави. :проблем',
+ 'unhealthy_backup_found_body' => 'Резервните копия за :application_name на диск :disk_name не са здрави.',
+ 'unhealthy_backup_found_not_reachable' => 'Дестинацията за резервни копия не може да бъде достигната. :грешка',
+ 'unhealthy_backup_found_empty' => 'Изобщо няма резервни копия на това приложение.',
+ 'unhealthy_backup_found_old' => 'Последното резервно копие, направено на :date, се счита за твърде старо.',
+ 'unhealthy_backup_found_unknown' => 'За съжаление не може да се определи точна причина.',
+ 'unhealthy_backup_found_full' => 'Резервните копия използват твърде много място за съхранение. Текущото използване е :disk_usage, което е по-високо от разрешеното ограничение на :disk_limit.',
+
+ 'no_backups_info' => 'Все още не са правени резервни копия',
+ 'application_name' => 'Име на приложението',
+ 'backup_name' => 'Име на резервно копие',
+ 'disk' => 'Диск',
+ 'newest_backup_size' => 'Най-новият размер на резервно копие',
+ 'number_of_backups' => 'Брой резервни копия',
+ 'total_storage_used' => 'Общо използвано дисково пространство',
+ 'newest_backup_date' => 'Най-нова дата на резервно копие',
+ 'oldest_backup_date' => 'Най-старата дата на резервно копие',
+];
diff --git a/lang/vendor/backup/bn/notifications.php b/lang/vendor/backup/bn/notifications.php
new file mode 100644
index 0000000..bd0bf81
--- /dev/null
+++ b/lang/vendor/backup/bn/notifications.php
@@ -0,0 +1,45 @@
+ 'এক্সসেপশন বার্তা: :message',
+ 'exception_trace' => 'এক্সসেপশন ট্রেস: :trace',
+ 'exception_message_title' => 'এক্সসেপশন message',
+ 'exception_trace_title' => 'এক্সসেপশন ট্রেস',
+
+ 'backup_failed_subject' => ':application_name এর ব্যাকআপ ব্যর্থ হয়েছে।',
+ 'backup_failed_body' => 'গুরুত্বপূর্ণঃ :application_name ব্যাক আপ করার সময় একটি ত্রুটি ঘটেছে।',
+
+ 'backup_successful_subject' => ':application_name এর নতুন ব্যাকআপ সফল হয়েছে।',
+ 'backup_successful_subject_title' => 'নতুন ব্যাকআপ সফল হয়েছে!',
+ 'backup_successful_body' => 'খুশির খবর, :application_name এর নতুন ব্যাকআপ :disk_name ডিস্কে সফলভাবে তৈরি হয়েছে।',
+
+ 'cleanup_failed_subject' => ':application_name ব্যাকআপগুলি সাফ করতে ব্যর্থ হয়েছে।',
+ 'cleanup_failed_body' => ':application_name ব্যাকআপগুলি সাফ করার সময় একটি ত্রুটি ঘটেছে।',
+
+ 'cleanup_successful_subject' => ':application_name এর ব্যাকআপগুলি সফলভাবে সাফ করা হয়েছে।',
+ 'cleanup_successful_subject_title' => 'ব্যাকআপগুলি সফলভাবে সাফ করা হয়েছে!',
+ 'cleanup_successful_body' => ':application_name এর ব্যাকআপগুলি :disk_name ডিস্ক থেকে সফলভাবে সাফ করা হয়েছে।',
+
+ 'healthy_backup_found_subject' => ':application_name এর ব্যাকআপগুলি :disk_name ডিস্কে স্বাস্থ্যকর অবস্থায় আছে।',
+ 'healthy_backup_found_subject_title' => ':application_name এর ব্যাকআপগুলি স্বাস্থ্যকর অবস্থায় আছে।',
+ 'healthy_backup_found_body' => ':application_name এর ব্যাকআপগুলি স্বাস্থ্যকর বিবেচনা করা হচ্ছে। Good job!',
+
+ 'unhealthy_backup_found_subject' => 'গুরুত্বপূর্ণঃ :application_name এর ব্যাকআপগুলি অস্বাস্থ্যকর অবস্থায় আছে।',
+ 'unhealthy_backup_found_subject_title' => 'গুরুত্বপূর্ণঃ :application_name এর ব্যাকআপগুলি অস্বাস্থ্যকর অবস্থায় আছে। :problem',
+ 'unhealthy_backup_found_body' => ':disk_name ডিস্কের :application_name এর ব্যাকআপগুলি অস্বাস্থ্যকর অবস্থায় আছে।',
+ 'unhealthy_backup_found_not_reachable' => 'ব্যাকআপ গন্তব্যে পৌঁছানো যায় নি। :error',
+ 'unhealthy_backup_found_empty' => 'এই অ্যাপ্লিকেশনটির কোনও ব্যাকআপ নেই।',
+ 'unhealthy_backup_found_old' => 'সর্বশেষ ব্যাকআপ যেটি :date এই তারিখে করা হয়েছে, সেটি খুব পুরানো।',
+ 'unhealthy_backup_found_unknown' => 'দুঃখিত, সঠিক কারণ নির্ধারণ করা সম্ভব হয়নি।',
+ 'unhealthy_backup_found_full' => 'ব্যাকআপগুলি অতিরিক্ত স্টোরেজ ব্যবহার করছে। বর্তমান ব্যবহারের পরিমান :disk_usage যা অনুমোদিত সীমা :disk_limit এর বেশি।',
+
+ 'no_backups_info' => 'কোনো ব্যাকআপ এখনও তৈরি হয়নি',
+ 'application_name' => 'আবেদনের নাম',
+ 'backup_name' => 'ব্যাকআপের নাম',
+ 'disk' => 'ডিস্ক',
+ 'newest_backup_size' => 'নতুন ব্যাকআপ আকার',
+ 'number_of_backups' => 'ব্যাকআপের সংখ্যা',
+ 'total_storage_used' => 'ব্যবহৃত মোট সঞ্চয়স্থান',
+ 'newest_backup_date' => 'নতুন ব্যাকআপের তারিখ',
+ 'oldest_backup_date' => 'পুরানো ব্যাকআপের তারিখ',
+];
diff --git a/lang/vendor/backup/cs/notifications.php b/lang/vendor/backup/cs/notifications.php
new file mode 100644
index 0000000..9a145d9
--- /dev/null
+++ b/lang/vendor/backup/cs/notifications.php
@@ -0,0 +1,45 @@
+ 'Zpráva výjimky: :message',
+ 'exception_trace' => 'Stopa výjimky: :trace',
+ 'exception_message_title' => 'Zpráva výjimky',
+ 'exception_trace_title' => 'Stopa výjimky',
+
+ 'backup_failed_subject' => 'Záloha :application_name neuspěla',
+ 'backup_failed_body' => 'Důležité: Při záloze :application_name se vyskytla chyba',
+
+ 'backup_successful_subject' => 'Úspěšná nová záloha :application_name',
+ 'backup_successful_subject_title' => 'Úspěšná nová záloha!',
+ 'backup_successful_body' => 'Dobrá zpráva, na disku jménem :disk_name byla úspěšně vytvořena nová záloha :application_name.',
+
+ 'cleanup_failed_subject' => 'Vyčištění záloh :application_name neuspělo.',
+ 'cleanup_failed_body' => 'Při čištění záloh :application_name se vyskytla chyba',
+
+ 'cleanup_successful_subject' => 'Vyčištění záloh :application_name úspěšné',
+ 'cleanup_successful_subject_title' => 'Vyčištění záloh bylo úspěšné!',
+ 'cleanup_successful_body' => 'Vyčištění záloh :application_name na disku jménem :disk_name bylo úspěšné.',
+
+ 'healthy_backup_found_subject' => 'Zálohy pro :application_name na disku :disk_name jsou zdravé',
+ 'healthy_backup_found_subject_title' => 'Zálohy pro :application_name jsou zdravé',
+ 'healthy_backup_found_body' => 'Zálohy pro :application_name jsou považovány za zdravé. Dobrá práce!',
+
+ 'unhealthy_backup_found_subject' => 'Důležité: Zálohy pro :application_name jsou nezdravé',
+ 'unhealthy_backup_found_subject_title' => 'Důležité: Zálohy pro :application_name jsou nezdravé. :problem',
+ 'unhealthy_backup_found_body' => 'Zálohy pro :application_name na disku :disk_name jsou nezdravé.',
+ 'unhealthy_backup_found_not_reachable' => 'Nelze se dostat k cíli zálohy. :error',
+ 'unhealthy_backup_found_empty' => 'Tato aplikace nemá vůbec žádné zálohy.',
+ 'unhealthy_backup_found_old' => 'Poslední záloha vytvořená dne :date je považována za příliš starou.',
+ 'unhealthy_backup_found_unknown' => 'Omlouváme se, nemůžeme určit přesný důvod.',
+ 'unhealthy_backup_found_full' => 'Zálohy zabírají příliš mnoho místa na disku. Aktuální využití disku je :disk_usage, což je vyšší než povolený limit :disk_limit.',
+
+ 'no_backups_info' => 'Zatím nebyly vytvořeny žádné zálohy',
+ 'application_name' => 'Název aplikace',
+ 'backup_name' => 'Název zálohy',
+ 'disk' => 'Disk',
+ 'newest_backup_size' => 'Velikost nejnovější zálohy',
+ 'number_of_backups' => 'Počet záloh',
+ 'total_storage_used' => 'Celková využitá kapacita úložiště',
+ 'newest_backup_date' => 'Datum nejnovější zálohy',
+ 'oldest_backup_date' => 'Datum nejstarší zálohy',
+];
diff --git a/lang/vendor/backup/da/notifications.php b/lang/vendor/backup/da/notifications.php
new file mode 100644
index 0000000..80b4697
--- /dev/null
+++ b/lang/vendor/backup/da/notifications.php
@@ -0,0 +1,45 @@
+ 'Fejlbesked: :message',
+ 'exception_trace' => 'Fejl trace: :trace',
+ 'exception_message_title' => 'Fejlbesked',
+ 'exception_trace_title' => 'Fejl trace',
+
+ 'backup_failed_subject' => 'Backup af :application_name fejlede',
+ 'backup_failed_body' => 'Vigtigt: Der skete en fejl under backup af :application_name',
+
+ 'backup_successful_subject' => 'Ny backup af :application_name oprettet',
+ 'backup_successful_subject_title' => 'Ny backup!',
+ 'backup_successful_body' => 'Gode nyheder - der blev oprettet en ny backup af :application_name på disken :disk_name.',
+
+ 'cleanup_failed_subject' => 'Oprydning af backups for :application_name fejlede.',
+ 'cleanup_failed_body' => 'Der skete en fejl under oprydning af backups for :application_name',
+
+ 'cleanup_successful_subject' => 'Oprydning af backups for :application_name gennemført',
+ 'cleanup_successful_subject_title' => 'Backup oprydning gennemført!',
+ 'cleanup_successful_body' => 'Oprydningen af backups for :application_name på disken :disk_name er gennemført.',
+
+ 'healthy_backup_found_subject' => 'Alle backups for :application_name på disken :disk_name er OK',
+ 'healthy_backup_found_subject_title' => 'Alle backups for :application_name er OK',
+ 'healthy_backup_found_body' => 'Alle backups for :application_name er ok. Godt gået!',
+
+ 'unhealthy_backup_found_subject' => 'Vigtigt: Backups for :application_name fejlbehæftede',
+ 'unhealthy_backup_found_subject_title' => 'Vigtigt: Backups for :application_name er fejlbehæftede. :problem',
+ 'unhealthy_backup_found_body' => 'Backups for :application_name på disken :disk_name er fejlbehæftede.',
+ 'unhealthy_backup_found_not_reachable' => 'Backup destinationen kunne ikke findes. :error',
+ 'unhealthy_backup_found_empty' => 'Denne applikation har ingen backups overhovedet.',
+ 'unhealthy_backup_found_old' => 'Den seneste backup fra :date er for gammel.',
+ 'unhealthy_backup_found_unknown' => 'Beklager, en præcis årsag kunne ikke findes.',
+ 'unhealthy_backup_found_full' => 'Backups bruger for meget plads. Nuværende disk forbrug er :disk_usage, hvilket er mere end den tilladte grænse på :disk_limit.',
+
+ 'no_backups_info' => 'Der blev ikke foretaget nogen sikkerhedskopier endnu',
+ 'application_name' => 'Applikationens navn',
+ 'backup_name' => 'Backup navn',
+ 'disk' => 'Disk',
+ 'newest_backup_size' => 'Nyeste backup-størrelse',
+ 'number_of_backups' => 'Antal sikkerhedskopier',
+ 'total_storage_used' => 'Samlet lagerplads brugt',
+ 'newest_backup_date' => 'Nyeste backup-størrelse',
+ 'oldest_backup_date' => 'Ældste backup-størrelse',
+];
diff --git a/lang/vendor/backup/de/notifications.php b/lang/vendor/backup/de/notifications.php
new file mode 100644
index 0000000..acce789
--- /dev/null
+++ b/lang/vendor/backup/de/notifications.php
@@ -0,0 +1,45 @@
+ 'Fehlermeldung: :message',
+ 'exception_trace' => 'Fehlerverfolgung: :trace',
+ 'exception_message_title' => 'Fehlermeldung',
+ 'exception_trace_title' => 'Fehlerverfolgung',
+
+ 'backup_failed_subject' => 'Backup von :application_name konnte nicht erstellt werden',
+ 'backup_failed_body' => 'Wichtig: Beim Backup von :application_name ist ein Fehler aufgetreten',
+
+ 'backup_successful_subject' => 'Erfolgreiches neues Backup von :application_name',
+ 'backup_successful_subject_title' => 'Erfolgreiches neues Backup!',
+ 'backup_successful_body' => 'Gute Nachrichten, ein neues Backup von :application_name wurde erfolgreich erstellt und in :disk_name gepeichert.',
+
+ 'cleanup_failed_subject' => 'Aufräumen der Backups von :application_name schlug fehl.',
+ 'cleanup_failed_body' => 'Beim aufräumen der Backups von :application_name ist ein Fehler aufgetreten',
+
+ 'cleanup_successful_subject' => 'Aufräumen der Backups von :application_name backups erfolgreich',
+ 'cleanup_successful_subject_title' => 'Aufräumen der Backups erfolgreich!',
+ 'cleanup_successful_body' => 'Aufräumen der Backups von :application_name in :disk_name war erfolgreich.',
+
+ 'healthy_backup_found_subject' => 'Die Backups von :application_name in :disk_name sind gesund',
+ 'healthy_backup_found_subject_title' => 'Die Backups von :application_name sind Gesund',
+ 'healthy_backup_found_body' => 'Die Backups von :application_name wurden als gesund eingestuft. Gute Arbeit!',
+
+ 'unhealthy_backup_found_subject' => 'Wichtig: Die Backups für :application_name sind nicht gesund',
+ 'unhealthy_backup_found_subject_title' => 'Wichtig: Die Backups für :application_name sind ungesund. :problem',
+ 'unhealthy_backup_found_body' => 'Die Backups für :application_name in :disk_name sind ungesund.',
+ 'unhealthy_backup_found_not_reachable' => 'Das Backup Ziel konnte nicht erreicht werden. :error',
+ 'unhealthy_backup_found_empty' => 'Es gibt für die Anwendung noch gar keine Backups.',
+ 'unhealthy_backup_found_old' => 'Das letzte Backup am :date ist zu lange her.',
+ 'unhealthy_backup_found_unknown' => 'Sorry, ein genauer Grund konnte nicht gefunden werden.',
+ 'unhealthy_backup_found_full' => 'Die Backups verbrauchen zu viel Platz. Aktuell wird :disk_usage belegt, dass ist höher als das erlaubte Limit von :disk_limit.',
+
+ 'no_backups_info' => 'Bisher keine Backups vorhanden',
+ 'application_name' => 'Applikationsname',
+ 'backup_name' => 'Backup Name',
+ 'disk' => 'Speicherort',
+ 'newest_backup_size' => 'Neuste Backup-Größe',
+ 'number_of_backups' => 'Anzahl Backups',
+ 'total_storage_used' => 'Gesamter genutzter Speicherplatz',
+ 'newest_backup_date' => 'Neustes Backup',
+ 'oldest_backup_date' => 'Ältestes Backup',
+];
diff --git a/lang/vendor/backup/en/notifications.php b/lang/vendor/backup/en/notifications.php
new file mode 100644
index 0000000..73811bd
--- /dev/null
+++ b/lang/vendor/backup/en/notifications.php
@@ -0,0 +1,45 @@
+ 'Exception message: :message',
+ 'exception_trace' => 'Exception trace: :trace',
+ 'exception_message_title' => 'Exception message',
+ 'exception_trace_title' => 'Exception trace',
+
+ 'backup_failed_subject' => 'Failed backup of :application_name',
+ 'backup_failed_body' => 'Important: An error occurred while backing up :application_name',
+
+ 'backup_successful_subject' => 'Successful new backup of :application_name',
+ 'backup_successful_subject_title' => 'Successful new backup!',
+ 'backup_successful_body' => 'Great news, a new backup of :application_name was successfully created on the disk named :disk_name.',
+
+ 'cleanup_failed_subject' => 'Cleaning up the backups of :application_name failed.',
+ 'cleanup_failed_body' => 'An error occurred while cleaning up the backups of :application_name',
+
+ 'cleanup_successful_subject' => 'Clean up of :application_name backups successful',
+ 'cleanup_successful_subject_title' => 'Clean up of backups successful!',
+ 'cleanup_successful_body' => 'The clean up of the :application_name backups on the disk named :disk_name was successful.',
+
+ 'healthy_backup_found_subject' => 'The backups for :application_name on disk :disk_name are healthy',
+ 'healthy_backup_found_subject_title' => 'The backups for :application_name are healthy',
+ 'healthy_backup_found_body' => 'The backups for :application_name are considered healthy. Good job!',
+
+ 'unhealthy_backup_found_subject' => 'Important: The backups for :application_name are unhealthy',
+ 'unhealthy_backup_found_subject_title' => 'Important: The backups for :application_name are unhealthy. :problem',
+ 'unhealthy_backup_found_body' => 'The backups for :application_name on disk :disk_name are unhealthy.',
+ 'unhealthy_backup_found_not_reachable' => 'The backup destination cannot be reached. :error',
+ 'unhealthy_backup_found_empty' => 'There are no backups of this application at all.',
+ 'unhealthy_backup_found_old' => 'The latest backup made on :date is considered too old.',
+ 'unhealthy_backup_found_unknown' => 'Sorry, an exact reason cannot be determined.',
+ 'unhealthy_backup_found_full' => 'The backups are using too much storage. Current usage is :disk_usage which is higher than the allowed limit of :disk_limit.',
+
+ 'no_backups_info' => 'No backups were made yet',
+ 'application_name' => 'Application name',
+ 'backup_name' => 'Backup name',
+ 'disk' => 'Disk',
+ 'newest_backup_size' => 'Newest backup size',
+ 'number_of_backups' => 'Number of backups',
+ 'total_storage_used' => 'Total storage used',
+ 'newest_backup_date' => 'Newest backup date',
+ 'oldest_backup_date' => 'Oldest backup date',
+];
diff --git a/lang/vendor/backup/es/notifications.php b/lang/vendor/backup/es/notifications.php
new file mode 100644
index 0000000..d2071b8
--- /dev/null
+++ b/lang/vendor/backup/es/notifications.php
@@ -0,0 +1,45 @@
+ 'Mensaje de la excepción: :message',
+ 'exception_trace' => 'Traza de la excepción: :trace',
+ 'exception_message_title' => 'Mensaje de la excepción',
+ 'exception_trace_title' => 'Traza de la excepción',
+
+ 'backup_failed_subject' => 'Copia de seguridad de :application_name fallida',
+ 'backup_failed_body' => 'Importante: Ocurrió un error al realizar la copia de seguridad de :application_name',
+
+ 'backup_successful_subject' => 'Se completó con éxito la copia de seguridad de :application_name',
+ 'backup_successful_subject_title' => '¡Nueva copia de seguridad creada con éxito!',
+ 'backup_successful_body' => 'Buenas noticias, una nueva copia de seguridad de :application_name fue creada con éxito en el disco llamado :disk_name.',
+
+ 'cleanup_failed_subject' => 'La limpieza de copias de seguridad de :application_name falló.',
+ 'cleanup_failed_body' => 'Ocurrió un error mientras se realizaba la limpieza de copias de seguridad de :application_name',
+
+ 'cleanup_successful_subject' => 'La limpieza de copias de seguridad de :application_name se completó con éxito',
+ 'cleanup_successful_subject_title' => '!Limpieza de copias de seguridad completada con éxito!',
+ 'cleanup_successful_body' => 'La limpieza de copias de seguridad de :application_name en el disco llamado :disk_name se completo con éxito.',
+
+ 'healthy_backup_found_subject' => 'Las copias de seguridad de :application_name en el disco :disk_name están en buen estado',
+ 'healthy_backup_found_subject_title' => 'Las copias de seguridad de :application_name están en buen estado',
+ 'healthy_backup_found_body' => 'Las copias de seguridad de :application_name se consideran en buen estado. ¡Buen trabajo!',
+
+ 'unhealthy_backup_found_subject' => 'Importante: Las copias de seguridad de :application_name están en mal estado',
+ 'unhealthy_backup_found_subject_title' => 'Importante: Las copias de seguridad de :application_name están en mal estado. :problem',
+ 'unhealthy_backup_found_body' => 'Las copias de seguridad de :application_name en el disco :disk_name están en mal estado.',
+ 'unhealthy_backup_found_not_reachable' => 'No se puede acceder al destino de la copia de seguridad. :error',
+ 'unhealthy_backup_found_empty' => 'No existe ninguna copia de seguridad de esta aplicación.',
+ 'unhealthy_backup_found_old' => 'La última copia de seguriad hecha en :date es demasiado antigua.',
+ 'unhealthy_backup_found_unknown' => 'Lo siento, no es posible determinar la razón exacta.',
+ 'unhealthy_backup_found_full' => 'Las copias de seguridad están ocupando demasiado espacio. El espacio utilizado actualmente es :disk_usage el cual es mayor que el límite permitido de :disk_limit.',
+
+ 'no_backups_info' => 'Aún no se hicieron copias de seguridad',
+ 'application_name' => 'Nombre de la aplicación',
+ 'backup_name' => 'Nombre de la copia de seguridad',
+ 'disk' => 'Disco',
+ 'newest_backup_size' => 'Tamaño de copia de seguridad más reciente',
+ 'number_of_backups' => 'Número de copias de seguridad',
+ 'total_storage_used' => 'Almacenamiento total utilizado',
+ 'newest_backup_date' => 'Fecha de la copia de seguridad más reciente',
+ 'oldest_backup_date' => 'Fecha de la copia de seguridad más antigua',
+];
diff --git a/lang/vendor/backup/fa/notifications.php b/lang/vendor/backup/fa/notifications.php
new file mode 100644
index 0000000..580a1f1
--- /dev/null
+++ b/lang/vendor/backup/fa/notifications.php
@@ -0,0 +1,45 @@
+ 'پیغام خطا: :message',
+ 'exception_trace' => 'جزییات خطا: :trace',
+ 'exception_message_title' => 'پیغام خطا',
+ 'exception_trace_title' => 'جزییات خطا',
+
+ 'backup_failed_subject' => 'پشتیبانگیری :application_name با خطا مواجه شد.',
+ 'backup_failed_body' => 'پیغام مهم: هنگام پشتیبانگیری از :application_name خطایی رخ داده است. ',
+
+ 'backup_successful_subject' => 'نسخه پشتیبان جدید :application_name با موفقیت ساخته شد.',
+ 'backup_successful_subject_title' => 'پشتیبانگیری موفق!',
+ 'backup_successful_body' => 'خبر خوب، به تازگی نسخه پشتیبان :application_name روی دیسک :disk_name با موفقیت ساخته شد. ',
+
+ 'cleanup_failed_subject' => 'پاکسازی نسخه پشتیبان :application_name انجام نشد.',
+ 'cleanup_failed_body' => 'هنگام پاکسازی نسخه پشتیبان :application_name خطایی رخ داده است.',
+
+ 'cleanup_successful_subject' => 'پاکسازی نسخه پشتیبان :application_name با موفقیت انجام شد.',
+ 'cleanup_successful_subject_title' => 'پاکسازی نسخه پشتیبان!',
+ 'cleanup_successful_body' => 'پاکسازی نسخه پشتیبان :application_name روی دیسک :disk_name با موفقیت انجام شد.',
+
+ 'healthy_backup_found_subject' => 'نسخه پشتیبان :application_name روی دیسک :disk_name سالم بود.',
+ 'healthy_backup_found_subject_title' => 'نسخه پشتیبان :application_name سالم بود.',
+ 'healthy_backup_found_body' => 'نسخه پشتیبان :application_name به نظر سالم میاد. دمت گرم!',
+
+ 'unhealthy_backup_found_subject' => 'خبر مهم: نسخه پشتیبان :application_name سالم نبود.',
+ 'unhealthy_backup_found_subject_title' => 'خبر مهم: نسخه پشتیبان :application_name سالم نبود. :problem',
+ 'unhealthy_backup_found_body' => 'نسخه پشتیبان :application_name روی دیسک :disk_name سالم نبود.',
+ 'unhealthy_backup_found_not_reachable' => 'مقصد پشتیبانگیری در دسترس نبود. :error',
+ 'unhealthy_backup_found_empty' => 'برای این برنامه هیچ نسخه پشتیبانی وجود ندارد.',
+ 'unhealthy_backup_found_old' => 'آخرین نسخه پشتیبان برای تاریخ :date است، که به نظر خیلی قدیمی میاد. ',
+ 'unhealthy_backup_found_unknown' => 'متاسفانه دلیل دقیقی قابل تعیین نیست.',
+ 'unhealthy_backup_found_full' => 'نسخههای پشتیبان حجم زیادی اشغال کردهاند. میزان دیسک استفادهشده :disk_usage است که از میزان مجاز :disk_limit فراتر رفته است. ',
+
+ 'no_backups_info' => 'هنوز نسخه پشتیبان تهیه نشده است',
+ 'application_name' => 'نام نرمافزار',
+ 'backup_name' => 'نام نسخه پشتیبان',
+ 'disk' => 'دیسک',
+ 'newest_backup_size' => 'اندازه جدیدترین نسخه پشتیبان',
+ 'number_of_backups' => 'تعداد نسخههای پشتیبان',
+ 'total_storage_used' => 'کل فضای ذخیرهسازی استفادهشده',
+ 'newest_backup_date' => 'تاریخ جدیدترین نسخه پشتیبان',
+ 'oldest_backup_date' => 'تاریخ قدیمیترین نسخه پشتیبان',
+];
diff --git a/lang/vendor/backup/fi/notifications.php b/lang/vendor/backup/fi/notifications.php
new file mode 100644
index 0000000..98bec62
--- /dev/null
+++ b/lang/vendor/backup/fi/notifications.php
@@ -0,0 +1,45 @@
+ 'Virheilmoitus: :message',
+ 'exception_trace' => 'Virhe, jäljitys: :trace',
+ 'exception_message_title' => 'Virheilmoitus',
+ 'exception_trace_title' => 'Virheen jäljitys',
+
+ 'backup_failed_subject' => ':application_name varmuuskopiointi epäonnistui',
+ 'backup_failed_body' => 'HUOM!: :application_name varmuuskoipionnissa tapahtui virhe',
+
+ 'backup_successful_subject' => ':application_name varmuuskopioitu onnistuneesti',
+ 'backup_successful_subject_title' => 'Uusi varmuuskopio!',
+ 'backup_successful_body' => 'Hyviä uutisia! :application_name on varmuuskopioitu levylle :disk_name.',
+
+ 'cleanup_failed_subject' => ':application_name varmuuskopioiden poistaminen epäonnistui.',
+ 'cleanup_failed_body' => ':application_name varmuuskopioiden poistamisessa tapahtui virhe.',
+
+ 'cleanup_successful_subject' => ':application_name varmuuskopiot poistettu onnistuneesti',
+ 'cleanup_successful_subject_title' => 'Varmuuskopiot poistettu onnistuneesti!',
+ 'cleanup_successful_body' => ':application_name varmuuskopiot poistettu onnistuneesti levyltä :disk_name.',
+
+ 'healthy_backup_found_subject' => ':application_name varmuuskopiot levyllä :disk_name ovat kunnossa',
+ 'healthy_backup_found_subject_title' => ':application_name varmuuskopiot ovat kunnossa',
+ 'healthy_backup_found_body' => ':application_name varmuuskopiot ovat kunnossa. Hieno homma!',
+
+ 'unhealthy_backup_found_subject' => 'HUOM!: :application_name varmuuskopiot ovat vialliset',
+ 'unhealthy_backup_found_subject_title' => 'HUOM!: :application_name varmuuskopiot ovat vialliset. :problem',
+ 'unhealthy_backup_found_body' => ':application_name varmuuskopiot levyllä :disk_name ovat vialliset.',
+ 'unhealthy_backup_found_not_reachable' => 'Varmuuskopioiden kohdekansio ei ole saatavilla. :error',
+ 'unhealthy_backup_found_empty' => 'Tästä sovelluksesta ei ole varmuuskopioita.',
+ 'unhealthy_backup_found_old' => 'Viimeisin varmuuskopio, luotu :date, on liian vanha.',
+ 'unhealthy_backup_found_unknown' => 'Virhe, tarkempaa tietoa syystä ei valitettavasti ole saatavilla.',
+ 'unhealthy_backup_found_full' => 'Varmuuskopiot vievät liikaa levytilaa. Tällä hetkellä käytössä :disk_usage, mikä on suurempi kuin sallittu tilavuus (:disk_limit).',
+
+ 'no_backups_info' => 'Varmuuskopioita ei vielä tehty',
+ 'application_name' => 'Sovelluksen nimi',
+ 'backup_name' => 'Varmuuskopion nimi',
+ 'disk' => 'Levy',
+ 'newest_backup_size' => 'Uusin varmuuskopion koko',
+ 'number_of_backups' => 'Varmuuskopioiden määrä',
+ 'total_storage_used' => 'Käytetty tallennustila yhteensä',
+ 'newest_backup_date' => 'Uusin varmuuskopion koko',
+ 'oldest_backup_date' => 'Vanhin varmuuskopion koko',
+];
diff --git a/lang/vendor/backup/fr/notifications.php b/lang/vendor/backup/fr/notifications.php
new file mode 100644
index 0000000..ad60a5c
--- /dev/null
+++ b/lang/vendor/backup/fr/notifications.php
@@ -0,0 +1,45 @@
+ "Message de l'exception : :message",
+ 'exception_trace' => "Trace de l'exception : :trace",
+ 'exception_message_title' => "Message de l'exception",
+ 'exception_trace_title' => "Trace de l'exception",
+
+ 'backup_failed_subject' => 'Échec de la sauvegarde de :application_name',
+ 'backup_failed_body' => 'Important : Une erreur est survenue lors de la sauvegarde de :application_name',
+
+ 'backup_successful_subject' => 'Succès de la sauvegarde de :application_name',
+ 'backup_successful_subject_title' => 'Sauvegarde créée avec succès !',
+ 'backup_successful_body' => 'Bonne nouvelle, une nouvelle sauvegarde de :application_name a été créée avec succès sur le disque nommé :disk_name.',
+
+ 'cleanup_failed_subject' => 'Le nettoyage des sauvegardes de :application_name a echoué.',
+ 'cleanup_failed_body' => 'Une erreur est survenue lors du nettoyage des sauvegardes de :application_name',
+
+ 'cleanup_successful_subject' => 'Succès du nettoyage des sauvegardes de :application_name',
+ 'cleanup_successful_subject_title' => 'Sauvegardes nettoyées avec succès !',
+ 'cleanup_successful_body' => 'Le nettoyage des sauvegardes de :application_name sur le disque nommé :disk_name a été effectué avec succès.',
+
+ 'healthy_backup_found_subject' => 'Les sauvegardes pour :application_name sur le disque :disk_name sont saines',
+ 'healthy_backup_found_subject_title' => 'Les sauvegardes pour :application_name sont saines',
+ 'healthy_backup_found_body' => 'Les sauvegardes pour :application_name sont considérées saines. Bon travail !',
+
+ 'unhealthy_backup_found_subject' => 'Important : Les sauvegardes pour :application_name sont corrompues',
+ 'unhealthy_backup_found_subject_title' => 'Important : Les sauvegardes pour :application_name sont corrompues. :problem',
+ 'unhealthy_backup_found_body' => 'Les sauvegardes pour :application_name sur le disque :disk_name sont corrompues.',
+ 'unhealthy_backup_found_not_reachable' => "La destination de la sauvegarde n'est pas accessible. :error",
+ 'unhealthy_backup_found_empty' => "Il n'y a aucune sauvegarde pour cette application.",
+ 'unhealthy_backup_found_old' => 'La dernière sauvegarde du :date est considérée trop vieille.',
+ 'unhealthy_backup_found_unknown' => 'Désolé, une raison exacte ne peut être déterminée.',
+ 'unhealthy_backup_found_full' => 'Les sauvegardes utilisent trop d\'espace disque. L\'utilisation actuelle est de :disk_usage alors que la limite autorisée est de :disk_limit.',
+
+ 'no_backups_info' => 'Aucune sauvegarde n\'a encore été effectuée',
+ 'application_name' => "Nom de l'application",
+ 'backup_name' => 'Nom de la sauvegarde',
+ 'disk' => 'Disque',
+ 'newest_backup_size' => 'Taille de la sauvegarde la plus récente',
+ 'number_of_backups' => 'Nombre de sauvegardes',
+ 'total_storage_used' => 'Stockage total utilisé',
+ 'newest_backup_date' => 'Date de la sauvegarde la plus récente',
+ 'oldest_backup_date' => 'Date de la sauvegarde la plus ancienne',
+];
diff --git a/lang/vendor/backup/he/notifications.php b/lang/vendor/backup/he/notifications.php
new file mode 100644
index 0000000..db3b35f
--- /dev/null
+++ b/lang/vendor/backup/he/notifications.php
@@ -0,0 +1,45 @@
+ 'הודעת חריגה: :message',
+ 'exception_trace' => 'מעקב חריגה: :trace',
+ 'exception_message_title' => 'הודעת חריגה',
+ 'exception_trace_title' => 'מעקב חריגה',
+
+ 'backup_failed_subject' => 'כשל בגיבוי של :application_name',
+ 'backup_failed_body' => 'חשוב: אירעה שגיאה במהלך גיבוי היישום :application_name',
+
+ 'backup_successful_subject' => 'גיבוי חדש מוצלח של :application_name',
+ 'backup_successful_subject_title' => 'גיבוי חדש מוצלח!',
+ 'backup_successful_body' => 'חדשות טובות, גיבוי חדש של :application_name נוצר בהצלחה על הדיסק בשם :disk_name.',
+
+ 'cleanup_failed_subject' => 'נכשל בניקוי הגיבויים של :application_name',
+ 'cleanup_failed_body' => 'אירעה שגיאה במהלך ניקוי הגיבויים של :application_name',
+
+ 'cleanup_successful_subject' => 'ניקוי הגיבויים של :application_name בוצע בהצלחה',
+ 'cleanup_successful_subject_title' => 'ניקוי הגיבויים בוצע בהצלחה!',
+ 'cleanup_successful_body' => 'ניקוי הגיבויים של :application_name על הדיסק בשם :disk_name בוצע בהצלחה.',
+
+ 'healthy_backup_found_subject' => 'הגיבויים של :application_name על הדיסק :disk_name תקינים',
+ 'healthy_backup_found_subject_title' => 'הגיבויים של :application_name תקינים',
+ 'healthy_backup_found_body' => 'הגיבויים של :application_name נחשבים לתקינים. עבודה טובה!',
+
+ 'unhealthy_backup_found_subject' => 'חשוב: הגיבויים של :application_name אינם תקינים',
+ 'unhealthy_backup_found_subject_title' => 'חשוב: הגיבויים של :application_name אינם תקינים. :problem',
+ 'unhealthy_backup_found_body' => 'הגיבויים של :application_name על הדיסק :disk_name אינם תקינים.',
+ 'unhealthy_backup_found_not_reachable' => 'לא ניתן להגיע ליעד הגיבוי. :error',
+ 'unhealthy_backup_found_empty' => 'אין גיבויים של היישום הזה בכלל.',
+ 'unhealthy_backup_found_old' => 'הגיבוי האחרון שנעשה בתאריך :date נחשב כישן מדי.',
+ 'unhealthy_backup_found_unknown' => 'מצטערים, לא ניתן לקבוע סיבה מדויקת.',
+ 'unhealthy_backup_found_full' => 'הגיבויים משתמשים בשטח אחסון רב מידי. שימוש הנוכחי הוא :disk_usage, שגבול המותר הוא :disk_limit.',
+
+ 'no_backups_info' => 'לא נעשו עדיין גיבויים',
+ 'application_name' => 'שם היישום',
+ 'backup_name' => 'שם הגיבוי',
+ 'disk' => 'דיסק',
+ 'newest_backup_size' => 'גודל הגיבוי החדש ביותר',
+ 'number_of_backups' => 'מספר הגיבויים',
+ 'total_storage_used' => 'סך האחסון המופעל',
+ 'newest_backup_date' => 'תאריך הגיבוי החדש ביותר',
+ 'oldest_backup_date' => 'תאריך הגיבוי הישן ביותר',
+];
diff --git a/lang/vendor/backup/hi/notifications.php b/lang/vendor/backup/hi/notifications.php
new file mode 100644
index 0000000..f812867
--- /dev/null
+++ b/lang/vendor/backup/hi/notifications.php
@@ -0,0 +1,45 @@
+ 'अपवाद संदेश: :message',
+ 'exception_trace' => 'अपवाद निशान: :trace',
+ 'exception_message_title' => 'अपवादी संदेश',
+ 'exception_trace_title' => 'अपवाद निशान',
+
+ 'backup_failed_subject' => ':application_name का बैकअप असफल रहा',
+ 'backup_failed_body' => 'जरूरी सुचना: :application_name का बैकअप लेते समय असफल रहे',
+
+ 'backup_successful_subject' => ':application_name का बैकअप सफल रहा',
+ 'backup_successful_subject_title' => 'बैकअप सफल रहा!',
+ 'backup_successful_body' => 'खुशखबर, :application_name का बैकअप :disk_name पर संग्रहित करने मे सफल रहे.',
+
+ 'cleanup_failed_subject' => ':application_name के बैकअप की सफाई असफल रही.',
+ 'cleanup_failed_body' => ':application_name के बैकअप की सफाई करते समय कुछ बाधा आयी है.',
+
+ 'cleanup_successful_subject' => ':application_name के बैकअप की सफाई सफल रही',
+ 'cleanup_successful_subject_title' => 'बैकअप की सफाई सफल रही!',
+ 'cleanup_successful_body' => ':application_name का बैकअप जो :disk_name नाम की डिस्क पर संग्रहित है, उसकी सफाई सफल रही.',
+
+ 'healthy_backup_found_subject' => ':disk_name नाम की डिस्क पर संग्रहित :application_name के बैकअप स्वस्थ है',
+ 'healthy_backup_found_subject_title' => ':application_name के सभी बैकअप स्वस्थ है',
+ 'healthy_backup_found_body' => 'बहुत बढ़िया! :application_name के सभी बैकअप स्वस्थ है.',
+
+ 'unhealthy_backup_found_subject' => 'जरूरी सुचना : :application_name के बैकअप अस्वस्थ है',
+ 'unhealthy_backup_found_subject_title' => 'जरूरी सुचना : :application_name के बैकअप :problem के बजेसे अस्वस्थ है',
+ 'unhealthy_backup_found_body' => ':disk_name नाम की डिस्क पर संग्रहित :application_name के बैकअप अस्वस्थ है',
+ 'unhealthy_backup_found_not_reachable' => ':error के बजेसे बैकअप की मंजिल तक पोहोच नहीं सकते.',
+ 'unhealthy_backup_found_empty' => 'इस एप्लीकेशन का कोई भी बैकअप नहीं है.',
+ 'unhealthy_backup_found_old' => 'हालहीमें :date को लिया हुआ बैकअप बहुत पुराना है.',
+ 'unhealthy_backup_found_unknown' => 'माफ़ कीजिये, सही कारण निर्धारित नहीं कर सकते.',
+ 'unhealthy_backup_found_full' => 'सभी बैकअप बहुत ज्यादा जगह का उपयोग कर रहे है. फ़िलहाल सभी बैकअप :disk_usage जगह का उपयोग कर रहे है, जो की :disk_limit अनुमति सीमा से अधिक का है.',
+
+ 'no_backups_info' => 'अभी तक कोई बैकअप नहीं बनाया गया था',
+ 'application_name' => 'आवेदन का नाम',
+ 'backup_name' => 'बैकअप नाम',
+ 'disk' => 'डिस्क',
+ 'newest_backup_size' => 'नवीनतम बैकअप आकार',
+ 'number_of_backups' => 'बैकअप की संख्या',
+ 'total_storage_used' => 'उपयोग किया गया कुल संग्रहण',
+ 'newest_backup_date' => 'नवीनतम बैकअप आकार',
+ 'oldest_backup_date' => 'सबसे पुराना बैकअप आकार',
+];
diff --git a/lang/vendor/backup/hr/notifications.php b/lang/vendor/backup/hr/notifications.php
new file mode 100644
index 0000000..0b12bfd
--- /dev/null
+++ b/lang/vendor/backup/hr/notifications.php
@@ -0,0 +1,45 @@
+ 'Greška: :message',
+ 'exception_trace' => 'Praćenje greške: :trace',
+ 'exception_message_title' => 'Greška',
+ 'exception_trace_title' => 'Praćenje greške',
+
+ 'backup_failed_subject' => 'Neuspješno sigurnosno kopiranje za :application_name',
+ 'backup_failed_body' => 'Važno: Došlo je do greške prilikom sigurnosnog kopiranja za :application_name',
+
+ 'backup_successful_subject' => 'Uspješno sigurnosno kopiranje za :application_name',
+ 'backup_successful_subject_title' => 'Uspješno sigurnosno kopiranje!',
+ 'backup_successful_body' => 'Nova sigurnosna kopija za :application_name je uspješno spremljena na disk :disk_name.',
+
+ 'cleanup_failed_subject' => 'Neuspješno čišćenje sigurnosnih kopija za :application_name',
+ 'cleanup_failed_body' => 'Došlo je do greške prilikom čišćenja sigurnosnih kopija za :application_name',
+
+ 'cleanup_successful_subject' => 'Uspješno čišćenje sigurnosnih kopija za :application_name',
+ 'cleanup_successful_subject_title' => 'Uspješno čišćenje sigurnosnih kopija!',
+ 'cleanup_successful_body' => 'Sigurnosne kopije za :application_name su uspješno očišćene s diska :disk_name.',
+
+ 'healthy_backup_found_subject' => 'Sigurnosne kopije za :application_name na disku :disk_name su zdrave',
+ 'healthy_backup_found_subject_title' => 'Sigurnosne kopije za :application_name su zdrave',
+ 'healthy_backup_found_body' => 'Sigurnosne kopije za :application_name se smatraju zdravima. Svaka čast!',
+
+ 'unhealthy_backup_found_subject' => 'Važno: Sigurnosne kopije za :application_name su nezdrave',
+ 'unhealthy_backup_found_subject_title' => 'Važno: Sigurnosne kopije za :application_name su nezdrave. :problem',
+ 'unhealthy_backup_found_body' => 'Sigurnosne kopije za :application_name na disku :disk_name su nezdrave.',
+ 'unhealthy_backup_found_not_reachable' => 'Destinacija sigurnosne kopije nije dohvatljiva. :error',
+ 'unhealthy_backup_found_empty' => 'Nijedna sigurnosna kopija ove aplikacije ne postoji.',
+ 'unhealthy_backup_found_old' => 'Zadnja sigurnosna kopija generirana na datum :date smatra se prestarom.',
+ 'unhealthy_backup_found_unknown' => 'Isprike, ali nije moguće odrediti razlog.',
+ 'unhealthy_backup_found_full' => 'Sigurnosne kopije zauzimaju previše prostora. Trenutno zauzeće je :disk_usage što je više od dozvoljenog ograničenja od :disk_limit.',
+
+ 'no_backups_info' => 'Nema sigurnosnih kopija',
+ 'application_name' => 'Naziv aplikacije',
+ 'backup_name' => 'Naziv sigurnosne kopije',
+ 'disk' => 'Disk',
+ 'newest_backup_size' => 'Veličina najnovije sigurnosne kopije',
+ 'number_of_backups' => 'Broj sigurnosnih kopija',
+ 'total_storage_used' => 'Ukupno zauzeće',
+ 'newest_backup_date' => 'Najnovija kopija na datum',
+ 'oldest_backup_date' => 'Najstarija kopija na datum',
+];
diff --git a/lang/vendor/backup/id/notifications.php b/lang/vendor/backup/id/notifications.php
new file mode 100644
index 0000000..12364b5
--- /dev/null
+++ b/lang/vendor/backup/id/notifications.php
@@ -0,0 +1,45 @@
+ 'Pesan pengecualian: :message',
+ 'exception_trace' => 'Jejak pengecualian: :trace',
+ 'exception_message_title' => 'Pesan pengecualian',
+ 'exception_trace_title' => 'Jejak pengecualian',
+
+ 'backup_failed_subject' => 'Gagal backup :application_name',
+ 'backup_failed_body' => 'Penting: Sebuah error terjadi ketika membackup :application_name',
+
+ 'backup_successful_subject' => 'Backup baru sukses dari :application_name',
+ 'backup_successful_subject_title' => 'Backup baru sukses!',
+ 'backup_successful_body' => 'Kabar baik, sebuah backup baru dari :application_name sukses dibuat pada disk bernama :disk_name.',
+
+ 'cleanup_failed_subject' => 'Membersihkan backup dari :application_name yang gagal.',
+ 'cleanup_failed_body' => 'Sebuah error teradi ketika membersihkan backup dari :application_name',
+
+ 'cleanup_successful_subject' => 'Sukses membersihkan backup :application_name',
+ 'cleanup_successful_subject_title' => 'Sukses membersihkan backup!',
+ 'cleanup_successful_body' => 'Pembersihan backup :application_name pada disk bernama :disk_name telah sukses.',
+
+ 'healthy_backup_found_subject' => 'Backup untuk :application_name pada disk :disk_name sehat',
+ 'healthy_backup_found_subject_title' => 'Backup untuk :application_name sehat',
+ 'healthy_backup_found_body' => 'Backup untuk :application_name dipertimbangkan sehat. Kerja bagus!',
+
+ 'unhealthy_backup_found_subject' => 'Penting: Backup untuk :application_name tidak sehat',
+ 'unhealthy_backup_found_subject_title' => 'Penting: Backup untuk :application_name tidak sehat. :problem',
+ 'unhealthy_backup_found_body' => 'Backup untuk :application_name pada disk :disk_name tidak sehat.',
+ 'unhealthy_backup_found_not_reachable' => 'Tujuan backup tidak dapat terjangkau. :error',
+ 'unhealthy_backup_found_empty' => 'Tidak ada backup pada aplikasi ini sama sekali.',
+ 'unhealthy_backup_found_old' => 'Backup terakhir dibuat pada :date dimana dipertimbahkan sudah sangat lama.',
+ 'unhealthy_backup_found_unknown' => 'Maaf, sebuah alasan persisnya tidak dapat ditentukan.',
+ 'unhealthy_backup_found_full' => 'Backup menggunakan terlalu banyak kapasitas penyimpanan. Penggunaan terkini adalah :disk_usage dimana lebih besar dari batas yang diperbolehkan yaitu :disk_limit.',
+
+ 'no_backups_info' => 'Belum ada backup yang dibuat',
+ 'application_name' => 'Nama aplikasi',
+ 'backup_name' => 'Nama cadangan',
+ 'disk' => 'Disk',
+ 'newest_backup_size' => 'Ukuran cadangan terbaru',
+ 'number_of_backups' => 'Jumlah cadangan',
+ 'total_storage_used' => 'Total penyimpanan yang digunakan',
+ 'newest_backup_date' => 'Ukuran cadangan terbaru',
+ 'oldest_backup_date' => 'Ukuran cadangan tertua',
+];
diff --git a/lang/vendor/backup/it/notifications.php b/lang/vendor/backup/it/notifications.php
new file mode 100644
index 0000000..e96618d
--- /dev/null
+++ b/lang/vendor/backup/it/notifications.php
@@ -0,0 +1,45 @@
+ "Messaggio dell'eccezione: :message",
+ 'exception_trace' => "Traccia dell'eccezione: :trace",
+ 'exception_message_title' => "Messaggio dell'eccezione",
+ 'exception_trace_title' => "Traccia dell'eccezione",
+
+ 'backup_failed_subject' => 'Fallito il backup di :application_name',
+ 'backup_failed_body' => 'Importante: Si è verificato un errore durante il backup di :application_name',
+
+ 'backup_successful_subject' => 'Creato nuovo backup di :application_name',
+ 'backup_successful_subject_title' => 'Nuovo backup creato!',
+ 'backup_successful_body' => 'Grande notizia, un nuovo backup di :application_name è stato creato con successo sul disco :disk_name.',
+
+ 'cleanup_failed_subject' => 'Pulizia dei backup di :application_name fallita.',
+ 'cleanup_failed_body' => 'Si è verificato un errore durante la pulizia dei backup di :application_name',
+
+ 'cleanup_successful_subject' => 'Pulizia dei backup di :application_name avvenuta con successo',
+ 'cleanup_successful_subject_title' => 'Pulizia dei backup avvenuta con successo!',
+ 'cleanup_successful_body' => 'La pulizia dei backup di :application_name sul disco :disk_name è avvenuta con successo.',
+
+ 'healthy_backup_found_subject' => 'I backup per :application_name sul disco :disk_name sono sani',
+ 'healthy_backup_found_subject_title' => 'I backup per :application_name sono sani',
+ 'healthy_backup_found_body' => 'I backup per :application_name sono considerati sani. Bel Lavoro!',
+
+ 'unhealthy_backup_found_subject' => 'Importante: i backup per :application_name sono corrotti',
+ 'unhealthy_backup_found_subject_title' => 'Importante: i backup per :application_name sono corrotti. :problem',
+ 'unhealthy_backup_found_body' => 'I backup per :application_name sul disco :disk_name sono corrotti.',
+ 'unhealthy_backup_found_not_reachable' => 'Impossibile raggiungere la destinazione di backup. :error',
+ 'unhealthy_backup_found_empty' => 'Non esiste alcun backup di questa applicazione.',
+ 'unhealthy_backup_found_old' => 'L\'ultimo backup fatto il :date è considerato troppo vecchio.',
+ 'unhealthy_backup_found_unknown' => 'Spiacenti, non è possibile determinare una ragione esatta.',
+ 'unhealthy_backup_found_full' => 'I backup utilizzano troppa memoria. L\'utilizzo corrente è :disk_usage che è superiore al limite consentito di :disk_limit.',
+
+ 'no_backups_info' => 'Non sono stati ancora effettuati backup',
+ 'application_name' => "Nome dell'applicazione",
+ 'backup_name' => 'Nome di backup',
+ 'disk' => 'Disco',
+ 'newest_backup_size' => 'Dimensione backup più recente',
+ 'number_of_backups' => 'Numero di backup',
+ 'total_storage_used' => 'Spazio di archiviazione totale utilizzato',
+ 'newest_backup_date' => 'Data del backup più recente',
+ 'oldest_backup_date' => 'Data del backup più vecchio',
+];
diff --git a/lang/vendor/backup/ja/notifications.php b/lang/vendor/backup/ja/notifications.php
new file mode 100644
index 0000000..1b57ca3
--- /dev/null
+++ b/lang/vendor/backup/ja/notifications.php
@@ -0,0 +1,45 @@
+ '例外のメッセージ: :message',
+ 'exception_trace' => '例外の追跡: :trace',
+ 'exception_message_title' => '例外のメッセージ',
+ 'exception_trace_title' => '例外の追跡',
+
+ 'backup_failed_subject' => ':application_name のバックアップに失敗しました。',
+ 'backup_failed_body' => '重要: :application_name のバックアップ中にエラーが発生しました。',
+
+ 'backup_successful_subject' => ':application_name のバックアップに成功しました。',
+ 'backup_successful_subject_title' => 'バックアップに成功しました!',
+ 'backup_successful_body' => '朗報です。ディスク :disk_name へ :application_name のバックアップが成功しました。',
+
+ 'cleanup_failed_subject' => ':application_name のバックアップ削除に失敗しました。',
+ 'cleanup_failed_body' => ':application_name のバックアップ削除中にエラーが発生しました。',
+
+ 'cleanup_successful_subject' => ':application_name のバックアップ削除に成功しました。',
+ 'cleanup_successful_subject_title' => 'バックアップ削除に成功しました!',
+ 'cleanup_successful_body' => 'ディスク :disk_name に保存された :application_name のバックアップ削除に成功しました。',
+
+ 'healthy_backup_found_subject' => 'ディスク :disk_name への :application_name のバックアップは正常です。',
+ 'healthy_backup_found_subject_title' => ':application_name のバックアップは正常です。',
+ 'healthy_backup_found_body' => ':application_name へのバックアップは正常です。いい仕事してますね!',
+
+ 'unhealthy_backup_found_subject' => '重要: :application_name のバックアップに異常があります。',
+ 'unhealthy_backup_found_subject_title' => '重要: :application_name のバックアップに異常があります。 :problem',
+ 'unhealthy_backup_found_body' => ':disk_name への :application_name のバックアップに異常があります。',
+ 'unhealthy_backup_found_not_reachable' => 'バックアップ先にアクセスできませんでした。 :error',
+ 'unhealthy_backup_found_empty' => 'このアプリケーションのバックアップは見つかりませんでした。',
+ 'unhealthy_backup_found_old' => ':date に保存された直近のバックアップが古すぎます。',
+ 'unhealthy_backup_found_unknown' => '申し訳ございません。予期せぬエラーです。',
+ 'unhealthy_backup_found_full' => 'バックアップがディスク容量を圧迫しています。現在の使用量 :disk_usage は、許可された限界値 :disk_limit を超えています。',
+
+ 'no_backups_info' => 'バックアップはまだ作成されていません',
+ 'application_name' => 'アプリケーション名',
+ 'backup_name' => 'バックアップ名',
+ 'disk' => 'ディスク',
+ 'newest_backup_size' => '最新のバックアップサイズ',
+ 'number_of_backups' => 'バックアップ数',
+ 'total_storage_used' => '使用された合計ストレージ',
+ 'newest_backup_date' => '最新のバックアップ日時',
+ 'oldest_backup_date' => '最も古いバックアップ日時',
+];
diff --git a/lang/vendor/backup/kk/notifications.php b/lang/vendor/backup/kk/notifications.php
new file mode 100644
index 0000000..2382e48
--- /dev/null
+++ b/lang/vendor/backup/kk/notifications.php
@@ -0,0 +1,45 @@
+ 'Қате туралы хабарлама: :message',
+ 'exception_trace' => 'Қате туралы мәліметтер: :trace',
+ 'exception_message_title' => 'Қате туралы хабарлама',
+ 'exception_trace_title' => 'Қате туралы мәліметтер',
+
+ 'backup_failed_subject' => ':application_name бағдарламасының резервтік көшірмесін жасау сәтсіз аяқталды',
+ 'backup_failed_body' => 'Маңызды: :application_name бағдарламасының резервтік көшірмесін жасау барысында қате орын алды',
+
+ 'backup_successful_subject' => ':application_name бағдарламасының жаңа резервтік көшірмесі сәтті құрылды',
+ 'backup_successful_subject_title' => 'Жаңа резервтік көшірме сәтті құрылды!',
+ 'backup_successful_body' => 'Жақсы жаңалық: :application_name бағдарламасының жаңа резервтік көшірмесі :disk_name дискінде сәтті құрылды.',
+
+ 'cleanup_failed_subject' => ':application_name бағдарламасының резервтік көшірмелерін тазалау сәтсіз аяқталды',
+ 'cleanup_failed_body' => ':application_name бағдарламасының резервтік көшірмелерін тазалау барысында қате орын алды',
+
+ 'cleanup_successful_subject' => ':application_name бағдарламасының резервтік көшірмелерін тазалау сәтті өтті',
+ 'cleanup_successful_subject_title' => 'Резервтік көшірмелерді тазалау сәтті аяқталды!',
+ 'cleanup_successful_body' => ':disk_name дискіндегі :application_name бағдарламасының резервтік көшірмелерін тазалау сәтті аяқталды.',
+
+ 'healthy_backup_found_subject' => ':disk_name дискіндегі :application_name бағдарламасының резервтік көшірмелері қалыпты күйде',
+ 'healthy_backup_found_subject_title' => ':application_name бағдарламасының резервтік көшірмелері қалыпты күйде',
+ 'healthy_backup_found_body' => ':application_name бағдарламасының резервтік көшірмелері толық тексеруден өтті. Өте жақсы!',
+
+ 'unhealthy_backup_found_subject' => 'Маңызды: :application_name бағдарламасының резервтік көшірмелері жарамсыз күйде',
+ 'unhealthy_backup_found_subject_title' => 'Маңызды: :application_name бағдарламасының резервтік көшірмелері жарамсыз күйде. :problem',
+ 'unhealthy_backup_found_body' => ':disk_name дискіндегі :application_name бағдарламасының резервтік көшірмелері жарамсыз күйде.',
+ 'unhealthy_backup_found_not_reachable' => 'Резервтік көшірме сақтау орнына қол жеткізу мүмкін емес. :error',
+ 'unhealthy_backup_found_empty' => 'Осы бағдарлама бойынша резервтік көшірмелер әлі жасалмаған.',
+ 'unhealthy_backup_found_old' => 'Соңғы резервтік көшірме (:date) тым ескі болып саналады.',
+ 'unhealthy_backup_found_unknown' => 'Кешіріңіз, нақты себебін анықтау мүмкін емес.',
+ 'unhealthy_backup_found_full' => 'Резервтік көшірмелер тым көп орын алып отыр. Ағымдағы пайдалану көлемі :disk_usage, бұл рұқсат етілген шектен :disk_limit аса жоғары.',
+
+ 'no_backups_info' => 'Әлі резервтік көшірме жасалмаған',
+ 'application_name' => 'Бағдарлама атауы',
+ 'backup_name' => 'Резервтік көшірме атауы',
+ 'disk' => 'Диск',
+ 'newest_backup_size' => 'Соңғы резервтік көшірменің көлемі',
+ 'number_of_backups' => 'Резервтік көшірмелер саны',
+ 'total_storage_used' => 'Жалпы қолданылған сақтау көлемі',
+ 'newest_backup_date' => 'Соңғы резервтік көшірме күні',
+ 'oldest_backup_date' => 'Ең ескі резервтік көшірме күні',
+];
diff --git a/lang/vendor/backup/ko/notifications.php b/lang/vendor/backup/ko/notifications.php
new file mode 100644
index 0000000..d13c0f9
--- /dev/null
+++ b/lang/vendor/backup/ko/notifications.php
@@ -0,0 +1,45 @@
+ '예외 메시지: :message',
+ 'exception_trace' => '예외 추적: :trace',
+ 'exception_message_title' => '예외 메시지',
+ 'exception_trace_title' => '예외 추적',
+
+ 'backup_failed_subject' => ':application_name 백업 실패',
+ 'backup_failed_body' => '중요: :application_name 백업 중 오류 발생',
+
+ 'backup_successful_subject' => ':application_name 백업 성공',
+ 'backup_successful_subject_title' => '백업이 성공적으로 완료되었습니다!',
+ 'backup_successful_body' => '좋은 소식입니다. :disk_name 디스크에 :application_name 백업이 성공적으로 완료되었습니다.',
+
+ 'cleanup_failed_subject' => ':application_name 백업 정리 실패',
+ 'cleanup_failed_body' => ':application_name 백업 정리 중 오류 발생',
+
+ 'cleanup_successful_subject' => ':application_name 백업 정리 성공',
+ 'cleanup_successful_subject_title' => '백업 정리가 성공적으로 완료되었습니다!',
+ 'cleanup_successful_body' => ':disk_name 디스크에 저장된 :application_name 백업 정리가 성공적으로 완료되었습니다.',
+
+ 'healthy_backup_found_subject' => ':application_name 백업은 정상입니다.',
+ 'healthy_backup_found_subject_title' => ':application_name 백업은 정상입니다.',
+ 'healthy_backup_found_body' => ':application_name 백업은 정상입니다. 수고하셨습니다!',
+
+ 'unhealthy_backup_found_subject' => '중요: :application_name 백업에 문제가 있습니다.',
+ 'unhealthy_backup_found_subject_title' => '중요: :application_name 백업에 문제가 있습니다. :problem',
+ 'unhealthy_backup_found_body' => ':disk_name 디스크에 :application_name 백업에 문제가 있습니다.',
+ 'unhealthy_backup_found_not_reachable' => '백업 위치에 액세스할 수 없습니다. :error',
+ 'unhealthy_backup_found_empty' => '이 애플리케이션에는 백업이 없습니다.',
+ 'unhealthy_backup_found_old' => ':date에 저장된 최신 백업이 너무 오래되었습니다.',
+ 'unhealthy_backup_found_unknown' => '죄송합니다. 예기치 않은 오류가 발생했습니다.',
+ 'unhealthy_backup_found_full' => '백업이 디스크 공간을 다 차지하고 있습니다. 현재 사용량 :disk_usage는 허용 한도 :disk_limit을 초과합니다.',
+
+ 'no_backups_info' => '아직 백업이 생성되지 않았습니다.',
+ 'application_name' => '애플리케이션 이름',
+ 'backup_name' => '백업 이름',
+ 'disk' => '디스크',
+ 'newest_backup_size' => '최신 백업 크기',
+ 'number_of_backups' => '백업 수',
+ 'total_storage_used' => '총 사용 스토리지',
+ 'newest_backup_date' => '최신 백업 날짜',
+ 'oldest_backup_date' => '가장 오래된 백업 날짜',
+];
diff --git a/lang/vendor/backup/nl/notifications.php b/lang/vendor/backup/nl/notifications.php
new file mode 100644
index 0000000..4887cbf
--- /dev/null
+++ b/lang/vendor/backup/nl/notifications.php
@@ -0,0 +1,45 @@
+ 'Fout bericht: :message',
+ 'exception_trace' => 'Fout trace: :trace',
+ 'exception_message_title' => 'Fout bericht',
+ 'exception_trace_title' => 'Fout trace',
+
+ 'backup_failed_subject' => 'Back-up van :application_name mislukt',
+ 'backup_failed_body' => 'Belangrijk: Er ging iets fout tijdens het maken van een back-up van :application_name',
+
+ 'backup_successful_subject' => 'Succesvolle nieuwe back-up van :application_name',
+ 'backup_successful_subject_title' => 'Succesvolle nieuwe back-up!',
+ 'backup_successful_body' => 'Goed nieuws, een nieuwe back-up van :application_name was succesvol aangemaakt op de schijf genaamd :disk_name.',
+
+ 'cleanup_failed_subject' => 'Het opschonen van de back-ups van :application_name is mislukt.',
+ 'cleanup_failed_body' => 'Er ging iets fout tijdens het opschonen van de back-ups van :application_name',
+
+ 'cleanup_successful_subject' => 'Opschonen van :application_name back-ups was succesvol.',
+ 'cleanup_successful_subject_title' => 'Opschonen van back-ups was succesvol!',
+ 'cleanup_successful_body' => 'Het opschonen van de :application_name back-ups op de schijf genaamd :disk_name was succesvol.',
+
+ 'healthy_backup_found_subject' => 'De back-ups voor :application_name op schijf :disk_name zijn gezond',
+ 'healthy_backup_found_subject_title' => 'De back-ups voor :application_name zijn gezond',
+ 'healthy_backup_found_body' => 'De back-ups voor :application_name worden als gezond beschouwd. Goed gedaan!',
+
+ 'unhealthy_backup_found_subject' => 'Belangrijk: De back-ups voor :application_name zijn niet meer gezond',
+ 'unhealthy_backup_found_subject_title' => 'Belangrijk: De back-ups voor :application_name zijn niet gezond. :problem',
+ 'unhealthy_backup_found_body' => 'De back-ups voor :application_name op schijf :disk_name zijn niet gezond.',
+ 'unhealthy_backup_found_not_reachable' => 'De back-upbestemming kon niet worden bereikt. :error',
+ 'unhealthy_backup_found_empty' => 'Er zijn geen back-ups van deze applicatie beschikbaar.',
+ 'unhealthy_backup_found_old' => 'De laatste back-up gemaakt op :date is te oud.',
+ 'unhealthy_backup_found_unknown' => 'Sorry, een exacte reden kon niet worden bepaald.',
+ 'unhealthy_backup_found_full' => 'De back-ups gebruiken te veel opslagruimte. Momenteel wordt er :disk_usage gebruikt wat hoger is dan de toegestane limiet van :disk_limit.',
+
+ 'no_backups_info' => 'Er zijn nog geen back-ups gemaakt',
+ 'application_name' => 'Naam van de toepassing',
+ 'backup_name' => 'Back-upnaam',
+ 'disk' => 'Schijf',
+ 'newest_backup_size' => 'Nieuwste back-upgrootte',
+ 'number_of_backups' => 'Aantal back-ups',
+ 'total_storage_used' => 'Totale gebruikte opslagruimte',
+ 'newest_backup_date' => 'Datum nieuwste back-up',
+ 'oldest_backup_date' => 'Datum oudste back-up',
+];
diff --git a/lang/vendor/backup/no/notifications.php b/lang/vendor/backup/no/notifications.php
new file mode 100644
index 0000000..e1d7019
--- /dev/null
+++ b/lang/vendor/backup/no/notifications.php
@@ -0,0 +1,45 @@
+ 'Exception: :message',
+ 'exception_trace' => 'Exception trace: :trace',
+ 'exception_message_title' => 'Exception',
+ 'exception_trace_title' => 'Exception trace',
+
+ 'backup_failed_subject' => 'Backup feilet for :application_name',
+ 'backup_failed_body' => 'Viktg: En feil oppstod under backing av :application_name',
+
+ 'backup_successful_subject' => 'Gjennomført backup av :application_name',
+ 'backup_successful_subject_title' => 'Gjennomført backup!',
+ 'backup_successful_body' => 'Gode nyheter, en ny backup av :application_name ble opprettet på disken :disk_name.',
+
+ 'cleanup_failed_subject' => 'Opprydding av backup for :application_name feilet.',
+ 'cleanup_failed_body' => 'En feil oppstod under opprydding av backups for :application_name',
+
+ 'cleanup_successful_subject' => 'Opprydding av backup for :application_name gjennomført',
+ 'cleanup_successful_subject_title' => 'Opprydding av backup gjennomført!',
+ 'cleanup_successful_body' => 'Oppryddingen av backup for :application_name på disken :disk_name har blitt gjennomført.',
+
+ 'healthy_backup_found_subject' => 'Alle backups for :application_name på disken :disk_name er OK',
+ 'healthy_backup_found_subject_title' => 'Alle backups for :application_name er OK',
+ 'healthy_backup_found_body' => 'Alle backups for :application_name er ok. Godt jobba!',
+
+ 'unhealthy_backup_found_subject' => 'Viktig: Backups for :application_name ikke OK',
+ 'unhealthy_backup_found_subject_title' => 'Viktig: Backups for :application_name er ikke OK. :problem',
+ 'unhealthy_backup_found_body' => 'Backups for :application_name på disken :disk_name er ikke OK.',
+ 'unhealthy_backup_found_not_reachable' => 'Kunne ikke finne backup-destinasjonen. :error',
+ 'unhealthy_backup_found_empty' => 'Denne applikasjonen mangler backups.',
+ 'unhealthy_backup_found_old' => 'Den siste backupem fra :date er for gammel.',
+ 'unhealthy_backup_found_unknown' => 'Beklager, kunne ikke finne nøyaktig årsak.',
+ 'unhealthy_backup_found_full' => 'Backups bruker for mye lagringsplass. Nåværende diskbruk er :disk_usage, som er mer enn den tillatte grensen på :disk_limit.',
+
+ 'no_backups_info' => 'Ingen sikkerhetskopier ble gjort ennå',
+ 'application_name' => 'Programnavn',
+ 'backup_name' => 'Navn på sikkerhetskopi',
+ 'disk' => 'Disk',
+ 'newest_backup_size' => 'Nyeste backup-størrelse',
+ 'number_of_backups' => 'Antall sikkerhetskopier',
+ 'total_storage_used' => 'Total lagring brukt',
+ 'newest_backup_date' => 'Nyeste backup-størrelse',
+ 'oldest_backup_date' => 'Eldste sikkerhetskopistørrelse',
+];
diff --git a/lang/vendor/backup/pl/notifications.php b/lang/vendor/backup/pl/notifications.php
new file mode 100644
index 0000000..5e79902
--- /dev/null
+++ b/lang/vendor/backup/pl/notifications.php
@@ -0,0 +1,45 @@
+ 'Błąd: :message',
+ 'exception_trace' => 'Zrzut błędu: :trace',
+ 'exception_message_title' => 'Błąd',
+ 'exception_trace_title' => 'Zrzut błędu',
+
+ 'backup_failed_subject' => 'Tworzenie kopii zapasowej aplikacji :application_name nie powiodło się',
+ 'backup_failed_body' => 'Ważne: Wystąpił błąd podczas tworzenia kopii zapasowej aplikacji :application_name',
+
+ 'backup_successful_subject' => 'Pomyślnie utworzono kopię zapasową aplikacji :application_name',
+ 'backup_successful_subject_title' => 'Nowa kopia zapasowa!',
+ 'backup_successful_body' => 'Wspaniała wiadomość, nowa kopia zapasowa aplikacji :application_name została pomyślnie utworzona na dysku o nazwie :disk_name.',
+
+ 'cleanup_failed_subject' => 'Czyszczenie kopii zapasowych aplikacji :application_name nie powiodło się.',
+ 'cleanup_failed_body' => 'Wystąpił błąd podczas czyszczenia kopii zapasowej aplikacji :application_name',
+
+ 'cleanup_successful_subject' => 'Kopie zapasowe aplikacji :application_name zostały pomyślnie wyczyszczone',
+ 'cleanup_successful_subject_title' => 'Kopie zapasowe zostały pomyślnie wyczyszczone!',
+ 'cleanup_successful_body' => 'Czyszczenie kopii zapasowych aplikacji :application_name na dysku :disk_name zakończone sukcesem.',
+
+ 'healthy_backup_found_subject' => 'Kopie zapasowe aplikacji :application_name na dysku :disk_name są poprawne',
+ 'healthy_backup_found_subject_title' => 'Kopie zapasowe aplikacji :application_name są poprawne',
+ 'healthy_backup_found_body' => 'Kopie zapasowe aplikacji :application_name są poprawne. Dobra robota!',
+
+ 'unhealthy_backup_found_subject' => 'Ważne: Kopie zapasowe aplikacji :application_name są niepoprawne',
+ 'unhealthy_backup_found_subject_title' => 'Ważne: Kopie zapasowe aplikacji :application_name są niepoprawne. :problem',
+ 'unhealthy_backup_found_body' => 'Kopie zapasowe aplikacji :application_name na dysku :disk_name są niepoprawne.',
+ 'unhealthy_backup_found_not_reachable' => 'Miejsce docelowe kopii zapasowej nie jest osiągalne. :error',
+ 'unhealthy_backup_found_empty' => 'W aplikacji nie ma żadnej kopii zapasowych tej aplikacji.',
+ 'unhealthy_backup_found_old' => 'Ostatnia kopia zapasowa wykonania dnia :date jest zbyt stara.',
+ 'unhealthy_backup_found_unknown' => 'Niestety, nie można ustalić dokładnego błędu.',
+ 'unhealthy_backup_found_full' => 'Kopie zapasowe zajmują zbyt dużo miejsca. Obecne użycie dysku :disk_usage jest większe od ustalonego limitu :disk_limit.',
+
+ 'no_backups_info' => 'Nie utworzono jeszcze kopii zapasowych',
+ 'application_name' => 'Nazwa aplikacji',
+ 'backup_name' => 'Nazwa kopii zapasowej',
+ 'disk' => 'Dysk',
+ 'newest_backup_size' => 'Najnowszy rozmiar kopii zapasowej',
+ 'number_of_backups' => 'Liczba kopii zapasowych',
+ 'total_storage_used' => 'Całkowite wykorzystane miejsce',
+ 'newest_backup_date' => 'Najnowszy rozmiar kopii zapasowej',
+ 'oldest_backup_date' => 'Najstarszy rozmiar kopii zapasowej',
+];
diff --git a/lang/vendor/backup/pt/notifications.php b/lang/vendor/backup/pt/notifications.php
new file mode 100644
index 0000000..835cfeb
--- /dev/null
+++ b/lang/vendor/backup/pt/notifications.php
@@ -0,0 +1,45 @@
+ 'Mensagem de exceção: :message',
+ 'exception_trace' => 'Rasto da exceção: :trace',
+ 'exception_message_title' => 'Mensagem de exceção',
+ 'exception_trace_title' => 'Rasto da exceção',
+
+ 'backup_failed_subject' => 'Falha no backup da aplicação :application_name',
+ 'backup_failed_body' => 'Importante: Ocorreu um erro ao executar o backup da aplicação :application_name',
+
+ 'backup_successful_subject' => 'Backup realizado com sucesso: :application_name',
+ 'backup_successful_subject_title' => 'Backup Realizado com Sucesso!',
+ 'backup_successful_body' => 'Boas notícias, foi criado um novo backup no disco :disk_name referente à aplicação :application_name.',
+
+ 'cleanup_failed_subject' => 'Falha na limpeza dos backups da aplicação :application_name.',
+ 'cleanup_failed_body' => 'Ocorreu um erro ao executar a limpeza dos backups da aplicação :application_name',
+
+ 'cleanup_successful_subject' => 'Limpeza dos backups da aplicação :application_name concluída!',
+ 'cleanup_successful_subject_title' => 'Limpeza dos backups concluída!',
+ 'cleanup_successful_body' => 'Concluída a limpeza dos backups da aplicação :application_name no disco :disk_name.',
+
+ 'healthy_backup_found_subject' => 'Os backups da aplicação :application_name no disco :disk_name estão em dia',
+ 'healthy_backup_found_subject_title' => 'Os backups da aplicação :application_name estão em dia',
+ 'healthy_backup_found_body' => 'Os backups da aplicação :application_name estão em dia. Bom trabalho!',
+
+ 'unhealthy_backup_found_subject' => 'Importante: Os backups da aplicação :application_name não estão em dia',
+ 'unhealthy_backup_found_subject_title' => 'Importante: Os backups da aplicação :application_name não estão em dia. :problem',
+ 'unhealthy_backup_found_body' => 'Os backups da aplicação :application_name no disco :disk_name não estão em dia.',
+ 'unhealthy_backup_found_not_reachable' => 'O destino dos backups não pode ser alcançado. :error',
+ 'unhealthy_backup_found_empty' => 'Não existem backups para essa aplicação.',
+ 'unhealthy_backup_found_old' => 'O último backup realizado em :date é demasiado antigo.',
+ 'unhealthy_backup_found_unknown' => 'Desculpe, impossível determinar a razão exata.',
+ 'unhealthy_backup_found_full' => 'Os backups estão a utilizar demasiado espaço de armazenamento. A utilização atual é de :disk_usage, o que é maior que o limite permitido de :disk_limit.',
+
+ 'no_backups_info' => 'Nenhum backup foi feito ainda',
+ 'application_name' => 'Nome da Aplicação',
+ 'backup_name' => 'Nome de backup',
+ 'disk' => 'Disco',
+ 'newest_backup_size' => 'Tamanho de backup mais recente',
+ 'number_of_backups' => 'Número de backups',
+ 'total_storage_used' => 'Armazenamento total usado',
+ 'newest_backup_date' => 'Data de backup mais recente',
+ 'oldest_backup_date' => 'Data de backup mais antiga',
+];
diff --git a/lang/vendor/backup/pt_BR/notifications.php b/lang/vendor/backup/pt_BR/notifications.php
new file mode 100644
index 0000000..406d4da
--- /dev/null
+++ b/lang/vendor/backup/pt_BR/notifications.php
@@ -0,0 +1,45 @@
+ 'Mensagem de exceção: :message',
+ 'exception_trace' => 'Rastreamento de exceção: :trace',
+ 'exception_message_title' => 'Mensagem de exceção',
+ 'exception_trace_title' => 'Rastreamento de exceção',
+
+ 'backup_failed_subject' => 'Falha no backup da aplicação :application_name',
+ 'backup_failed_body' => 'Importante: Ocorreu um erro ao fazer o backup da aplicação :application_name',
+
+ 'backup_successful_subject' => 'Backup realizado com sucesso: :application_name',
+ 'backup_successful_subject_title' => 'Backup Realizado com sucesso!',
+ 'backup_successful_body' => 'Boas notícias, um novo backup da aplicação :application_name foi criado no disco :disk_name.',
+
+ 'cleanup_failed_subject' => 'Falha na limpeza dos backups da aplicação :application_name.',
+ 'cleanup_failed_body' => 'Um erro ocorreu ao fazer a limpeza dos backups da aplicação :application_name',
+
+ 'cleanup_successful_subject' => 'Limpeza dos backups da aplicação :application_name concluída!',
+ 'cleanup_successful_subject_title' => 'Limpeza dos backups concluída!',
+ 'cleanup_successful_body' => 'A limpeza dos backups da aplicação :application_name no disco :disk_name foi concluída.',
+
+ 'healthy_backup_found_subject' => 'Os backups da aplicação :application_name no disco :disk_name estão em dia',
+ 'healthy_backup_found_subject_title' => 'Os backups da aplicação :application_name estão em dia',
+ 'healthy_backup_found_body' => 'Os backups da aplicação :application_name estão em dia. Bom trabalho!',
+
+ 'unhealthy_backup_found_subject' => 'Importante: Os backups da aplicação :application_name não estão em dia',
+ 'unhealthy_backup_found_subject_title' => 'Importante: Os backups da aplicação :application_name não estão em dia. :problem',
+ 'unhealthy_backup_found_body' => 'Os backups da aplicação :application_name no disco :disk_name não estão em dia.',
+ 'unhealthy_backup_found_not_reachable' => 'O destino dos backups não pode ser alcançado. :error',
+ 'unhealthy_backup_found_empty' => 'Não existem backups para essa aplicação.',
+ 'unhealthy_backup_found_old' => 'O último backup realizado em :date é considerado muito antigo.',
+ 'unhealthy_backup_found_unknown' => 'Desculpe, a exata razão não pode ser encontrada.',
+ 'unhealthy_backup_found_full' => 'Os backups estão usando muito espaço de armazenamento. A utilização atual é de :disk_usage, o que é maior que o limite permitido de :disk_limit.',
+
+ 'no_backups_info' => 'Nenhum backup foi feito ainda',
+ 'application_name' => 'Nome da Aplicação',
+ 'backup_name' => 'Nome de backup',
+ 'disk' => 'Disco',
+ 'newest_backup_size' => 'Tamanho do backup mais recente',
+ 'number_of_backups' => 'Número de backups',
+ 'total_storage_used' => 'Armazenamento total usado',
+ 'newest_backup_date' => 'Data do backup mais recente',
+ 'oldest_backup_date' => 'Data do backup mais antigo',
+];
diff --git a/lang/vendor/backup/ro/notifications.php b/lang/vendor/backup/ro/notifications.php
new file mode 100644
index 0000000..0e8bc91
--- /dev/null
+++ b/lang/vendor/backup/ro/notifications.php
@@ -0,0 +1,45 @@
+ 'Cu excepția mesajului: :message',
+ 'exception_trace' => 'Urmă excepţie: :trace',
+ 'exception_message_title' => 'Mesaj de excepție',
+ 'exception_trace_title' => 'Urmă excepţie',
+
+ 'backup_failed_subject' => 'Nu s-a putut face copie de rezervă pentru :application_name',
+ 'backup_failed_body' => 'Important: A apărut o eroare în timpul generării copiei de rezervă pentru :application_name',
+
+ 'backup_successful_subject' => 'Copie de rezervă efectuată cu succes pentru :application_name',
+ 'backup_successful_subject_title' => 'O nouă copie de rezervă a fost efectuată cu succes!',
+ 'backup_successful_body' => 'Vești bune, o nouă copie de rezervă pentru :application_name a fost creată cu succes pe discul cu numele :disk_name.',
+
+ 'cleanup_failed_subject' => 'Curățarea copiilor de rezervă pentru :application_name nu a reușit.',
+ 'cleanup_failed_body' => 'A apărut o eroare în timpul curățirii copiilor de rezervă pentru :application_name',
+
+ 'cleanup_successful_subject' => 'Curățarea copiilor de rezervă pentru :application_name a fost făcută cu succes',
+ 'cleanup_successful_subject_title' => 'Curățarea copiilor de rezervă a fost făcută cu succes!',
+ 'cleanup_successful_body' => 'Curățarea copiilor de rezervă pentru :application_name de pe discul cu numele :disk_name a fost făcută cu succes.',
+
+ 'healthy_backup_found_subject' => 'Copiile de rezervă pentru :application_name de pe discul :disk_name sunt în regulă',
+ 'healthy_backup_found_subject_title' => 'Copiile de rezervă pentru :application_name sunt în regulă',
+ 'healthy_backup_found_body' => 'Copiile de rezervă pentru :application_name sunt considerate în regulă. Bună treabă!',
+
+ 'unhealthy_backup_found_subject' => 'Important: Copiile de rezervă pentru :application_name nu sunt în regulă',
+ 'unhealthy_backup_found_subject_title' => 'Important: Copiile de rezervă pentru :application_name nu sunt în regulă. :problem',
+ 'unhealthy_backup_found_body' => 'Copiile de rezervă pentru :application_name de pe discul :disk_name nu sunt în regulă.',
+ 'unhealthy_backup_found_not_reachable' => 'Nu se poate ajunge la destinația copiilor de rezervă. :error',
+ 'unhealthy_backup_found_empty' => 'Nu există copii de rezervă ale acestei aplicații.',
+ 'unhealthy_backup_found_old' => 'Cea mai recentă copie de rezervă făcută la :date este considerată prea veche.',
+ 'unhealthy_backup_found_unknown' => 'Ne pare rău, un motiv exact nu poate fi determinat.',
+ 'unhealthy_backup_found_full' => 'Copiile de rezervă folosesc prea mult spațiu de stocare. Utilizarea curentă este de :disk_usage care este mai mare decât limita permisă de :disk_limit.',
+
+ 'no_backups_info' => 'Nu s-au făcut încă copii de rezervă',
+ 'application_name' => 'Numele aplicatiei',
+ 'backup_name' => 'Numele de rezervă',
+ 'disk' => 'Disc',
+ 'newest_backup_size' => 'Cea mai nouă dimensiune de rezervă',
+ 'number_of_backups' => 'Număr de copii de rezervă',
+ 'total_storage_used' => 'Spațiu total de stocare utilizat',
+ 'newest_backup_date' => 'Cea mai nouă dimensiune de rezervă',
+ 'oldest_backup_date' => 'Cea mai veche dimensiune de rezervă',
+];
diff --git a/lang/vendor/backup/ru/notifications.php b/lang/vendor/backup/ru/notifications.php
new file mode 100644
index 0000000..d58beb7
--- /dev/null
+++ b/lang/vendor/backup/ru/notifications.php
@@ -0,0 +1,45 @@
+ 'Сообщение об ошибке: :message',
+ 'exception_trace' => 'Сведения об ошибке: :trace',
+ 'exception_message_title' => 'Сообщение об ошибке',
+ 'exception_trace_title' => 'Сведения об ошибке',
+
+ 'backup_failed_subject' => 'Не удалось сделать резервную копию :application_name',
+ 'backup_failed_body' => 'Внимание: Произошла ошибка во время резервного копирования :application_name',
+
+ 'backup_successful_subject' => 'Успешно создана новая резервная копия :application_name',
+ 'backup_successful_subject_title' => 'Успешно создана новая резервная копия!',
+ 'backup_successful_body' => 'Отличная новость, новая резервная копия :application_name успешно создана и сохранена на диск :disk_name.',
+
+ 'cleanup_failed_subject' => 'Не удалось очистить резервные копии :application_name',
+ 'cleanup_failed_body' => 'Произошла ошибка при очистке резервных копий :application_name',
+
+ 'cleanup_successful_subject' => 'Очистка от резервных копий :application_name прошла успешно',
+ 'cleanup_successful_subject_title' => 'Очистка резервных копий прошла успешно!',
+ 'cleanup_successful_body' => 'Очистка от старых резервных копий :application_name на диске :disk_name прошла успешно.',
+
+ 'healthy_backup_found_subject' => 'Резервные копии :application_name с диска :disk_name исправны',
+ 'healthy_backup_found_subject_title' => 'Резервные копии :application_name исправны',
+ 'healthy_backup_found_body' => 'Резервные копии :application_name считаются исправными. Хорошая работа!',
+
+ 'unhealthy_backup_found_subject' => 'Внимание: резервные копии :application_name неисправны',
+ 'unhealthy_backup_found_subject_title' => 'Внимание: резервные копии для :application_name неисправны. :problem',
+ 'unhealthy_backup_found_body' => 'Резервные копии для :application_name на диске :disk_name неисправны.',
+ 'unhealthy_backup_found_not_reachable' => 'Не удается достичь места назначения резервной копии. :error',
+ 'unhealthy_backup_found_empty' => 'Резервные копии для этого приложения отсутствуют.',
+ 'unhealthy_backup_found_old' => 'Последнее резервное копирование созданное :date является устаревшим.',
+ 'unhealthy_backup_found_unknown' => 'Извините, точная причина не может быть определена.',
+ 'unhealthy_backup_found_full' => 'Резервные копии используют слишком много памяти. Используется :disk_usage что выше допустимого предела: :disk_limit.',
+
+ 'no_backups_info' => 'Резервных копий еще не было',
+ 'application_name' => 'Имя приложения',
+ 'backup_name' => 'Имя резервной копии',
+ 'disk' => 'Диск',
+ 'newest_backup_size' => 'Размер последней резервной копии',
+ 'number_of_backups' => 'Количество резервных копий',
+ 'total_storage_used' => 'Общий объем используемого хранилища',
+ 'newest_backup_date' => 'Дата последней резервной копии',
+ 'oldest_backup_date' => 'Дата самой старой резервной копии',
+];
diff --git a/lang/vendor/backup/sk/notifications.php b/lang/vendor/backup/sk/notifications.php
new file mode 100644
index 0000000..1d95e44
--- /dev/null
+++ b/lang/vendor/backup/sk/notifications.php
@@ -0,0 +1,45 @@
+ 'Správa výnimky: :message',
+ 'exception_trace' => 'Stopa výnimky: :trace',
+ 'exception_message_title' => 'Správa výnimky',
+ 'exception_trace_title' => 'Stopa výnimky',
+
+ 'backup_failed_subject' => 'Záloha :application_name zlyhala',
+ 'backup_failed_body' => 'Dôležité: Pri zálohovaní :application_name sa vyskytla chyba',
+
+ 'backup_successful_subject' => 'Úspešná nová záloha :application_name',
+ 'backup_successful_subject_title' => 'Úspešná nová záloha!',
+ 'backup_successful_body' => 'Dobrá správa, na disku s názvom :disk_name bola úspešne vytvorená nová záloha :application_name.',
+
+ 'cleanup_failed_subject' => 'Vyčistenie záloh :application_name zlyhalo.',
+ 'cleanup_failed_body' => 'Pri čistení záloh :application_name sa vyskytla chyba',
+
+ 'cleanup_successful_subject' => 'Vyčistenie záloh :application_name bolo úspešné',
+ 'cleanup_successful_subject_title' => 'Vyčistenie záloh bolo úspešné!',
+ 'cleanup_successful_body' => 'Vyčistenie záloh :application_name na disku s názvom :disk_name bolo úspešné.',
+
+ 'healthy_backup_found_subject' => 'Zálohy pre :application_name na disku :disk_name sú zdravé',
+ 'healthy_backup_found_subject_title' => 'Zálohy pre :application_name sú zdravé',
+ 'healthy_backup_found_body' => 'Zálohy pre :application_name sa považujú za zdravé. Dobrá práca!',
+
+ 'unhealthy_backup_found_subject' => 'Dôležité: Zálohy pre :application_name sú nezdravé',
+ 'unhealthy_backup_found_subject_title' => 'Dôležité: Zálohy pre :application_name sú nezdravé. :problem',
+ 'unhealthy_backup_found_body' => 'Zálohy pre :application_name na disku :disk_name sú nezdravé.',
+ 'unhealthy_backup_found_not_reachable' => 'Nemožno sa dostať k cieľu zálohy. :error',
+ 'unhealthy_backup_found_empty' => 'Táto aplikácia nemá žiadne zálohy.',
+ 'unhealthy_backup_found_old' => 'Posledná záloha vytvorená dňa :date sa považuje za príliš starú.',
+ 'unhealthy_backup_found_unknown' => 'Ospravedlňujeme sa, nemôžeme určiť presný dôvod.',
+ 'unhealthy_backup_found_full' => 'Zálohy zaberajú príliš veľa miesta na disku. Aktuálne využitie disku je :disk_usage, čo je viac ako povolený limit :disk_limit.',
+
+ 'no_backups_info' => 'Zatiaľ neboli vytvorené žiadne zálohy',
+ 'application_name' => 'Názov aplikácie',
+ 'backup_name' => 'Názov zálohy',
+ 'disk' => 'Disk',
+ 'newest_backup_size' => 'Veľkosť najnovšej zálohy',
+ 'number_of_backups' => 'Počet záloh',
+ 'total_storage_used' => 'Celková využitá kapacita úložiska',
+ 'newest_backup_date' => 'Dátum najnovšej zálohy',
+ 'oldest_backup_date' => 'Dátum najstaršej zálohy',
+];
diff --git a/lang/vendor/backup/tr/notifications.php b/lang/vendor/backup/tr/notifications.php
new file mode 100644
index 0000000..64cfa5a
--- /dev/null
+++ b/lang/vendor/backup/tr/notifications.php
@@ -0,0 +1,45 @@
+ 'Hata mesajı: :message',
+ 'exception_trace' => 'Hata izleri: :trace',
+ 'exception_message_title' => 'Hata mesajı',
+ 'exception_trace_title' => 'Hata izleri',
+
+ 'backup_failed_subject' => 'Yedeklenemedi :application_name',
+ 'backup_failed_body' => 'Önemli: Yedeklenirken bir hata oluştu :application_name',
+
+ 'backup_successful_subject' => 'Başarılı :application_name yeni yedeklemesi',
+ 'backup_successful_subject_title' => 'Başarılı bir yeni yedekleme!',
+ 'backup_successful_body' => 'Harika bir haber, :application_name ait yeni bir yedekleme :disk_name adlı diskte başarıyla oluşturuldu.',
+
+ 'cleanup_failed_subject' => ':application_name yedeklemeleri temizlenmesi başarısız.',
+ 'cleanup_failed_body' => ':application_name yedeklerini temizlerken bir hata oluştu ',
+
+ 'cleanup_successful_subject' => ':application_name yedeklemeleri temizlenmesi başarılı.',
+ 'cleanup_successful_subject_title' => 'Yedeklerin temizlenmesi başarılı!',
+ 'cleanup_successful_body' => ':application_name yedeklemeleri temizlenmesi, :disk_name diskinden silindi',
+
+ 'healthy_backup_found_subject' => ':application_name yedeklenmesi, :disk_name adlı diskte sağlıklı',
+ 'healthy_backup_found_subject_title' => ':application_name yedeklenmesi sağlıklı',
+ 'healthy_backup_found_body' => ':application_name için yapılan yedeklemeler sağlıklı sayılır. Aferin!',
+
+ 'unhealthy_backup_found_subject' => 'Önemli: :application_name için yedeklemeler sağlıksız',
+ 'unhealthy_backup_found_subject_title' => 'Önemli: :application_name için yedeklemeler sağlıksız. :problem',
+ 'unhealthy_backup_found_body' => 'Yedeklemeler: :application_name disk: :disk_name sağlıksız.',
+ 'unhealthy_backup_found_not_reachable' => 'Yedekleme hedefine ulaşılamıyor. :error',
+ 'unhealthy_backup_found_empty' => 'Bu uygulamanın yedekleri yok.',
+ 'unhealthy_backup_found_old' => ':date tarihinde yapılan en son yedekleme çok eski kabul ediliyor.',
+ 'unhealthy_backup_found_unknown' => 'Üzgünüm, kesin bir sebep belirlenemiyor.',
+ 'unhealthy_backup_found_full' => 'Yedeklemeler çok fazla depolama alanı kullanıyor. Şu anki kullanım: :disk_usage, izin verilen sınırdan yüksek: :disk_limit.',
+
+ 'no_backups_info' => 'Henüz yedekleme yapılmadı',
+ 'application_name' => 'Uygulama Adı',
+ 'backup_name' => 'Yedek adı',
+ 'disk' => 'Disk',
+ 'newest_backup_size' => 'En yeni yedekleme boyutu',
+ 'number_of_backups' => 'Yedekleme sayısı',
+ 'total_storage_used' => 'Kullanılan toplam depolama alanı',
+ 'newest_backup_date' => 'En yeni yedekleme tarihi',
+ 'oldest_backup_date' => 'En eski yedekleme tarihi',
+];
diff --git a/lang/vendor/backup/uk/notifications.php b/lang/vendor/backup/uk/notifications.php
new file mode 100644
index 0000000..6f6f83b
--- /dev/null
+++ b/lang/vendor/backup/uk/notifications.php
@@ -0,0 +1,45 @@
+ 'Повідомлення про помилку: :message',
+ 'exception_trace' => 'Деталі помилки: :trace',
+ 'exception_message_title' => 'Повідомлення помилки',
+ 'exception_trace_title' => 'Деталі помилки',
+
+ 'backup_failed_subject' => 'Не вдалось зробити резервну копію :application_name',
+ 'backup_failed_body' => 'Увага: Трапилась помилка під час резервного копіювання :application_name',
+
+ 'backup_successful_subject' => 'Успішне резервне копіювання :application_name',
+ 'backup_successful_subject_title' => 'Успішно створена резервна копія!',
+ 'backup_successful_body' => 'Чудова новина, нова резервна копія :application_name успішно створена і збережена на диск :disk_name.',
+
+ 'cleanup_failed_subject' => 'Не вдалось очистити резервні копії :application_name',
+ 'cleanup_failed_body' => 'Сталася помилка під час очищення резервних копій :application_name',
+
+ 'cleanup_successful_subject' => 'Успішне очищення від резервних копій :application_name',
+ 'cleanup_successful_subject_title' => 'Очищення резервних копій пройшло вдало!',
+ 'cleanup_successful_body' => 'Очищенно від старих резервних копій :application_name на диску :disk_name пойшло успішно.',
+
+ 'healthy_backup_found_subject' => 'Резервна копія :application_name з диску :disk_name установлена',
+ 'healthy_backup_found_subject_title' => 'Резервна копія :application_name установлена',
+ 'healthy_backup_found_body' => 'Резервна копія :application_name успішно установлена. Хороша робота!',
+
+ 'unhealthy_backup_found_subject' => 'Увага: резервна копія :application_name не установилась',
+ 'unhealthy_backup_found_subject_title' => 'Увага: резервна копія для :application_name не установилась. :problem',
+ 'unhealthy_backup_found_body' => 'Резервна копія для :application_name на диску :disk_name не установилась.',
+ 'unhealthy_backup_found_not_reachable' => 'Резервна копія не змогла установитись. :error',
+ 'unhealthy_backup_found_empty' => 'Резервні копії для цього додатку відсутні.',
+ 'unhealthy_backup_found_old' => 'Останнє резервне копіювання створено :date є застарілим.',
+ 'unhealthy_backup_found_unknown' => 'Вибачте, але ми не змогли визначити точну причину.',
+ 'unhealthy_backup_found_full' => 'Резервні копії використовують занадто багато пам`яті. Використовується :disk_usage що вище за допустиму межу :disk_limit.',
+
+ 'no_backups_info' => 'Резервних копій ще не було зроблено',
+ 'application_name' => 'Назва програми',
+ 'backup_name' => 'Резервне ім’я',
+ 'disk' => 'Диск',
+ 'newest_backup_size' => 'Найновіший розмір резервної копії',
+ 'number_of_backups' => 'Кількість резервних копій',
+ 'total_storage_used' => 'Загальний обсяг використаного сховища',
+ 'newest_backup_date' => 'Найновіший розмір резервної копії',
+ 'oldest_backup_date' => 'Найстаріший розмір резервної копії',
+];
diff --git a/lang/vendor/backup/zh_CN/notifications.php b/lang/vendor/backup/zh_CN/notifications.php
new file mode 100644
index 0000000..7927084
--- /dev/null
+++ b/lang/vendor/backup/zh_CN/notifications.php
@@ -0,0 +1,45 @@
+ '异常信息: :message',
+ 'exception_trace' => '异常跟踪: :trace',
+ 'exception_message_title' => '异常信息',
+ 'exception_trace_title' => '异常跟踪',
+
+ 'backup_failed_subject' => ':application_name 备份失败',
+ 'backup_failed_body' => '重要说明:备份 :application_name 时发生错误',
+
+ 'backup_successful_subject' => ':application_name 备份成功',
+ 'backup_successful_subject_title' => '备份成功!',
+ 'backup_successful_body' => '好消息, :application_name 备份成功,位于磁盘 :disk_name 中。',
+
+ 'cleanup_failed_subject' => '清除 :application_name 的备份失败。',
+ 'cleanup_failed_body' => '清除备份 :application_name 时发生错误',
+
+ 'cleanup_successful_subject' => '成功清除 :application_name 的备份',
+ 'cleanup_successful_subject_title' => '成功清除备份!',
+ 'cleanup_successful_body' => '成功清除 :disk_name 磁盘上 :application_name 的备份。',
+
+ 'healthy_backup_found_subject' => ':disk_name 磁盘上 :application_name 的备份是健康的',
+ 'healthy_backup_found_subject_title' => ':application_name 的备份是健康的',
+ 'healthy_backup_found_body' => ':application_name 的备份是健康的。干的好!',
+
+ 'unhealthy_backup_found_subject' => '重要说明::application_name 的备份不健康',
+ 'unhealthy_backup_found_subject_title' => '重要说明::application_name 备份不健康。 :problem',
+ 'unhealthy_backup_found_body' => ':disk_name 磁盘上 :application_name 的备份不健康。',
+ 'unhealthy_backup_found_not_reachable' => '无法访问备份目标。 :error',
+ 'unhealthy_backup_found_empty' => '根本没有此应用程序的备份。',
+ 'unhealthy_backup_found_old' => '最近的备份创建于 :date ,太旧了。',
+ 'unhealthy_backup_found_unknown' => '对不起,确切原因无法确定。',
+ 'unhealthy_backup_found_full' => '备份占用了太多存储空间。当前占用了 :disk_usage ,高于允许的限制 :disk_limit。',
+
+ 'no_backups_info' => '尚未进行任何备份',
+ 'application_name' => '应用名称',
+ 'backup_name' => '备份名称',
+ 'disk' => '磁盘',
+ 'newest_backup_size' => '最新备份大小',
+ 'number_of_backups' => '备份数量',
+ 'total_storage_used' => '使用的总存储量',
+ 'newest_backup_date' => '最新备份大小',
+ 'oldest_backup_date' => '最旧的备份大小',
+];
diff --git a/lang/vendor/backup/zh_TW/notifications.php b/lang/vendor/backup/zh_TW/notifications.php
new file mode 100644
index 0000000..7bc7dcb
--- /dev/null
+++ b/lang/vendor/backup/zh_TW/notifications.php
@@ -0,0 +1,45 @@
+ '異常訊息: :message',
+ 'exception_trace' => '異常追蹤: :trace',
+ 'exception_message_title' => '異常訊息',
+ 'exception_trace_title' => '異常追蹤',
+
+ 'backup_failed_subject' => ':application_name 備份失敗',
+ 'backup_failed_body' => '重要說明:備份 :application_name 時發生錯誤',
+
+ 'backup_successful_subject' => ':application_name 備份成功',
+ 'backup_successful_subject_title' => '備份成功!',
+ 'backup_successful_body' => '好消息, :application_name 備份成功,位於磁碟 :disk_name 中。',
+
+ 'cleanup_failed_subject' => '清除 :application_name 的備份失敗。',
+ 'cleanup_failed_body' => '清除備份 :application_name 時發生錯誤',
+
+ 'cleanup_successful_subject' => '成功清除 :application_name 的備份',
+ 'cleanup_successful_subject_title' => '成功清除備份!',
+ 'cleanup_successful_body' => '成功清除 :disk_name 磁碟上 :application_name 的備份。',
+
+ 'healthy_backup_found_subject' => ':disk_name 磁碟上 :application_name 的備份是健康的',
+ 'healthy_backup_found_subject_title' => ':application_name 的備份是健康的',
+ 'healthy_backup_found_body' => ':application_name 的備份是健康的。幹的好!',
+
+ 'unhealthy_backup_found_subject' => '重要說明::application_name 的備份不健康',
+ 'unhealthy_backup_found_subject_title' => '重要說明::application_name 備份不健康。 :problem',
+ 'unhealthy_backup_found_body' => ':disk_name 磁碟上 :application_name 的備份不健康。',
+ 'unhealthy_backup_found_not_reachable' => '無法訪問備份目標。 :error',
+ 'unhealthy_backup_found_empty' => '根本沒有此應用程序的備份。',
+ 'unhealthy_backup_found_old' => '最近的備份創建於 :date ,太舊了。',
+ 'unhealthy_backup_found_unknown' => '對不起,確切原因無法確定。',
+ 'unhealthy_backup_found_full' => '備份佔用了太多存儲空間。當前佔用了 :disk_usage ,高於允許的限制 :disk_limit。',
+
+ 'no_backups_info' => '尚未進行任何備份',
+ 'application_name' => '應用名稱',
+ 'backup_name' => '備份名稱',
+ 'disk' => '磁碟',
+ 'newest_backup_size' => '最新備份大小',
+ 'number_of_backups' => '備份數量',
+ 'total_storage_used' => '使用的總存儲量',
+ 'newest_backup_date' => '最新備份大小',
+ 'oldest_backup_date' => '最早的備份大小',
+];
diff --git a/misc/ENSAYO_PRESENTACION.md b/misc/ENSAYO_PRESENTACION.md
new file mode 100644
index 0000000..4328868
--- /dev/null
+++ b/misc/ENSAYO_PRESENTACION.md
@@ -0,0 +1,191 @@
+# 🎤 Guión de Ensayo — Presentación OnAPB
+### Taller de Integración — FCYT UADER | 7 de Abril 2026, 18:30 hs
+**Presentadores:** Lautaro · Fabricio
+
+---
+
+> **Objetivo de la demo:** ~20 minutos en total.
+> Mostrar el sistema funcionando en producción (onapb.com) de principio a fin.
+> El hilo conductor es una historia: *un jugador real pide un QR para un partido, y el admin lo escanea en la puerta.*
+
+---
+
+## 🎬 Estructura general
+
+| Bloque | Quién | Tiempo estimado |
+|---|---|---|
+| **Introducción** (qué es OnAPB y qué problema resuelve) | Lautaro | 2 min |
+| **Demo pública** (home, eventos, noticias, promos) | Fabricio | 3 min |
+| **Demo jugador** (login, solicitar QR, ver QRs) | Lautaro | 5 min |
+| **Demo admin** (panel, gestión, escanear QR) | Fabricio | 6 min |
+| **Demo técnica** (tests, backup) | Lautaro | 2 min |
+| **Cierre** (conclusiones, pendientes, mejoras) | Ambos | 2 min |
+
+---
+
+## 🟠 Bloque 1 — Introducción (Lautaro)
+
+**Lautaro dice:**
+
+> *"Buenas tardes. Somos Lautaro y Fabricio, y vamos a presentar OnAPB: un sistema web de gestión para asociaciones de básquetbol.*
+>
+> *El problema que resuelve es simple: hasta ahora, la administración de clubes, jugadores y el acceso a los partidos se hacía completamente en papel o con planillas de Excel desconectadas. Nosotros digitalizamos eso.*
+>
+> *El sistema está en producción en onapb.com y tiene cargados actualmente 35 equipos y alrededor de 3500 jugadores reales. Fue construido con Laravel 12, MySQL y Bootstrap 5."*
+
+👉 **Abrí el navegador en onapb.com mientras hablás.**
+
+---
+
+## 🟢 Bloque 2 — Demo Pública (Fabricio)
+
+**Fabricio toma el control del teclado/mouse.**
+
+### 2.1 Home
+- Mostrá el carrusel/hero de la portada. Decí brevemente:
+ > *"Esta es la página pública. Cualquier persona, sin registrarse, puede ver la cartelera de partidos, las noticias y los sponsors de la liga."*
+
+### 2.2 Eventos
+- Navegá a `/eventos`.
+- Hacé clic en un partido concreto (elegí uno que tenga equipos asignados y estado *Próximo*).
+ > *"Acá se ve el detalle del partido: los equipos, la categoría, la fecha y la sede."*
+
+### 2.3 Torneos (posiciones + goleadores)
+- Navegá a la sección de torneos.
+- Mostrá la tabla de posiciones y la tabla de goleadores.
+ > *"El sistema también gestiona torneos con grupos, posiciones acumuladas y ranking de goleadores. Cuando hay playoffs, se genera el bracket automáticamente."*
+
+### 2.4 Promos y Noticias (rápido)
+- 15 segundos nomás: mostrá que existen.
+ > *"También tenemos noticias publicables y locales con beneficios para los miembros."*
+
+---
+
+## 🔵 Bloque 3 — Demo Jugador (Lautaro)
+
+**Lautaro toma el control. Este es el bloque más importante de cara a la historia principal.**
+
+### 3.1 Login como Jugador
+- Ir a la pantalla de login (botón en el menú).
+- Pestaña *"Jugadores / Aficionados"*.
+- Ingresar un DNI y contraseña reales de un jugador de prueba.
+ > *"Los jugadores se loguean con su DNI y contraseña. El captcha de Cloudflare Turnstile protege contra bots."*
+- Clic en **Ingresar**. El sistema redirige a la home ya logueado.
+
+### 3.2 Panel de Usuario
+- Ir a `/panel-usuario`.
+ > *"Este es el panel personal del jugador. Ve sus datos, a qué club pertenece, a qué equipo está asignado, y su categoría, que se calcula automáticamente en base a la fecha de nacimiento."*
+
+### 3.3 ⭐ Solicitar QR para un Partido *(paso a paso detallado)*
+
+Este es el **momento central de la demo**. Hacelo con calma.
+
+1. Desde el panel o desde el menú, navegá a `/eventos`.
+2. Hacé clic en el partido preparado con anticipación (uno con estado *Próximo* y que tenga al jugador logueado en uno de los equipos).
+3. Mostrá el detalle del partido.
+ > *"En el detalle del evento, el jugador puede solicitar su QR. El sistema verifica automáticamente si pertenece a alguno de los equipos del partido."*
+4. Hacé clic en **"Solicitar QR"**.
+5. El sistema procesa y redirige a "Mis QRs".
+ > *"Como el jugador pertenece al equipo local, el sistema le genera 3 QRs: uno para él y dos para que les dé a familiares o acompañantes. Si fuera de categoría Libre, obtendría 1 QR con 50% de descuento."*
+
+### 3.4 Mis QRs
+- Mostrá la pantalla de Mis QRs.
+- Ampliá uno de los QR para que se vea bien grande.
+ > *"Cada QR es único, tiene un solo uso. Su estado es 'Válido' hasta que es escaneado, momento en que pasa a 'Usado'.*
+ >
+ > *Además, el jugador recibe estos QRs por correo electrónico automáticamente."*
+
+- **Consejo:** si podés, mostrá que el correo llegó a la bandeja (tené el mail abierto en otra pestaña).
+
+### 3.5 Cerrar sesión del jugador
+- Clic en **Cerrar sesión**.
+
+---
+
+## 🔴 Bloque 4 — Demo Administrador (Fabricio)
+
+**Fabricio toma el control.**
+
+### 4.1 Login como SuperAdmin
+- Login con usuario y contraseña del superadmin.
+ > *"Ahora ingresamos como Súper Administrador."*
+- Clic en **Ingresar**.
+
+### 4.2 Dashboard Admin
+- Mostrá el dashboard con las estadísticas globales (clubes, equipos, jugadores totales).
+ > *"El panel de administración tiene visión global de todo el sistema."*
+
+### 4.3 Gestión de Jugadores (rápido)
+- Ir a `/admin/jugadores`.
+- Mostrá el listado con búsqueda.
+ > *"Podemos buscar cualquier jugador. Hay 3500 cargados actualmente."*
+- Hacé clic en un jugador para mostrar el formulario de edición. No guardes nada.
+ > *"El admin puede editar datos, cambiar el club o eliminar un jugador. Si se elimina, usamos SoftDelete: no se borra físicamente, es recuperable."*
+
+### 4.4 Importación CSV (30 segundos)
+- Mostrá el botón de importar en `/admin/jugadores`.
+ > *"Una funcionalidad clave fue la importación masiva desde CSV. El sistema soporta el formato oficial de CAB — la Confederación Argentina de Básquet — y también formatos internos. Importamos los 3500 jugadores desde esas planillas."*
+
+### 4.5 Gestión de Eventos (rápido)
+- Ir a `/admin/eventos`.
+ > *"El admin calendariza los partidos, asigna equipos y controla el estado. Cuando se cargan los resultados, el evento pasa automáticamente a Finalizado y los puntos impactan en la tabla de posiciones."*
+
+### 4.6 ⭐ Escanear el QR *(paso a paso detallado)*
+
+Este es el **cierre de la historia**. Hacelo en vivo.
+
+1. Ir a `/admin/escanear-qr`.
+ > *"Esta es la herramienta para usar en la puerta del gimnasio el día del partido. El admin de turno selecciona el evento."*
+2. Seleccioná el mismo partido del que Lautaro sacó el QR antes.
+3. Activá la cámara o usá el campo de texto para ingresar el ID del QR.
+ > *"El árbitro o responsable escanea el código. El sistema valida en tiempo real."*
+4. Escaneá (o pegá el ID del QR generado antes).
+ > *"¿Ven? El sistema muestra el nombre del titular, el tipo de QR —en este caso 'invitado'— y lo pasa a estado Usado. Si alguien intentara entrar con ese mismo QR de nuevo, el sistema lo rechazaría."*
+5. Mostrá el resultado: QR validado, nombre del jugador, estado actualizado.
+
+---
+
+## 🟣 Bloque 5 — Demo Técnica (Lautaro)
+
+**Lautaro toma el control. Abrí una terminal.**
+
+### 5.1 Correr los tests
+```bash
+php artisan test
+```
+ > *"El proyecto tiene una suite de pruebas automatizadas con PHPUnit. Tenemos 5 archivos de Feature Tests que cubren el login de los 3 tipos de usuario, el acceso denegado, la solicitud de QR y la prevención de duplicados."*
+
+- Mostrá que todos pasan en verde.
+
+### 5.2 Sistema de Backup
+```bash
+php artisan backup:run --only-db --disable-notifications
+```
+ > *"También integramos spatie/laravel-backup para las copias de seguridad automáticas de la base de datos. Esto cubre el requisito de recuperación ante fallos."*
+
+### 5.3 SoftDeletes (explicación verbal, sin demo)
+ > *"Además, los modelos críticos —Club, Equipo, Jugador, Evento— implementan SoftDeletes. Esto significa que ningún dato crítico se borra permanentemente: si un admin elimina algo por error, los registros siguen en la base de datos con un timestamp de eliminación y son recuperables."*
+
+---
+
+## ⚪ Bloque 6 — Cierre (Ambos)
+
+**Turno de Fabricio primero:**
+
+> *"En cuanto a lo que quedó pendiente: la integración de pagos con Banco Macro —que llamamos Fase 6— no se implementó porque requiere credenciales y aprobación formal del banco, algo que está fuera del alcance académico. Sin embargo, la arquitectura está preparada para recibirla: tenemos el modelo `PagoMp` y la estructura de datos lista."*
+
+**Cierre de Lautaro:**
+
+> *"Como reflexión del equipo: el mayor desafío fue modelar correctamente la lógica de negocio real de una asociación de básquet —categorías por edad, pases entre clubes, permisos por rol— sin que el sistema se volviera rígido. El uso de SoftDeletes, servicios desacoplados y el scheduler nos permitió tener algo robusto y mantenible.*
+>
+> *Quedamos a disposición para preguntas."*
+
+---
+
+## ⚠️ Tips para el día de la presentación
+
+- **Elegí con anticipación** el jugador de prueba y el evento para el demo del QR. Verificá que el jugador esté en uno de los equipos del partido.
+- **Tené el correo abierto** en una pestaña aparte para mostrar que llegó el mail con el QR.
+- Si el profesor pregunta algo que no saben, **no inventen**: digan *"ese detalle lo tenemos en el código, si quiere lo revisamos juntos"*.
+- **Turno de hablar**: si uno habla, el otro maneja el mouse, y viceversa. No hablen los dos a la vez.
+- **Cronometren** el ensayo. Si lleva más de 25 minutos, recorten las partes "rápido".
diff --git a/misc/GESTION-JUGADORES.md b/misc/GESTION-JUGADORES.md
new file mode 100644
index 0000000..6bddf71
--- /dev/null
+++ b/misc/GESTION-JUGADORES.md
@@ -0,0 +1,43 @@
+# Gestión de Jugadores: Roles y Funcionalidades
+
+Este documento detalla las capacidades y restricciones para los distintos tipos de administradores al crear o importar jugadores en el sistema OnAPB.
+
+## 1. Creación Manual (Formulario Individual)
+
+| Característica | Súper Administrador (Rol 1) | Administrador de Club (Rol 2) |
+| :--- | :--- | :--- |
+| **Club de Origen** | **Editable:** Puede seleccionar cualquier club registrado. | **Editable:** Puede seleccionar cualquier club registrado (ej: para pases entre clubes). |
+| **Club Actual** | **Editable:** Puede asignar al jugador a cualquier club. | **Bloqueado:** El jugador se asigna automáticamente a su propio club. |
+| **Validación de DNI** | Bloqueado si el DNI ya existe. Muestra el club actual del jugador. | Bloqueado si el DNI ya existe. Muestra el club actual del jugador. |
+| **Generación de ID** | Automática: Basada en Club Origen + Año Nacimiento + Secuencia. | Automática: Basada en Club Origen + Año Nacimiento + Secuencia. |
+| **Estado Inicial** | Siempre `Inactivo`. Debe completarse en `/asociate`. | Siempre `Inactivo`. Debe completarse en `/asociate`. |
+
+## 2. Importación Masiva (Archivo .CSV)
+
+**Formato del archivo:** `DNI; Apellido; Nombre; ddmmaaaa; id_club_origen`
+
+| Característica | Súper Administrador (Rol 1) | Administrador de Club (Rol 2) |
+| :--- | :--- | :--- |
+| **Clubes de Origen Permitidos** | Cualquier ID de club. | **Solo su propio Club** o ID 99 (Default). |
+| **Asignación de Club Actual** | Se asigna el mismo ID del Club de Origen. | **Forzado** al ID del club del administrador. |
+| **Gestión de Errores** | Omite duplicados si el DNI ya existe. | Omite duplicados; bloquea filas con IDs de otros clubes. |
+
+---
+
+## Reglas Generales de Validación
+
+### Control de Duplicados (DNI)
+Independientemente del rol, el sistema no permite registros duplicados por DNI.
+- **Mensaje de Error:** *"No se puede registrar al jugador dado que ya pertenece al club [Nombre del Club]."*
+- Esta validación asegura que no se creen registros paralelos para el mismo jugador.
+
+### Generación del `id_jugador`
+El ID del jugador es un código único compuesto: `CCYYSSS`.
+- `CC`: ID del Club de Origen.
+- `YY`: Últimos dos dígitos del año de nacimiento.
+- `SSS`: Secuencia incremental (ej: 01, 02) para ese club y ese año.
+
+### Datos Automáticos
+- **Edad:** Se calcula automáticamente a partir de la fecha de nacimiento.
+- **Categoría:** Es dinámica. Se calcula cada año basándose en el año de nacimiento (ej: U15 para jugadores que cumplen 14 o 15 años en el año corriente).
+- **Contraseña:** Si no se especifica, queda pendiente hasta la activación por el usuario.
diff --git a/misc/HOJA-DE-RUTA.md b/misc/HOJA-DE-RUTA.md
new file mode 100644
index 0000000..7f53696
--- /dev/null
+++ b/misc/HOJA-DE-RUTA.md
@@ -0,0 +1,92 @@
+# Hoja de Ruta - Migración a Laravel
+
+## Fase 1: Fundamentos (✅ Completado)
+- Modelos Eloquent
+- Controladores RESTful
+- Rutas API
+- Layout base + Welcome
+
+## Fase 2: Autenticación (✅ Completado)
+- AuthController (login/logout player + admin)
+- Recuperar contraseña (token + email + vista reset ✅)
+- Registro de jugadores/aficionados
+- Middlewares de autenticación
+
+## Fase 3: Vistas Públicas (✅ Completado)
+- Vista eventos (lista)
+- Vista evento_detalle
+- Vista promos (mapa/lista)
+- Vista asociate (registro)
+- Vista noticias
+
+## Fase 4: Panel de Usuario (✅ Completado)
+- Dashboard usuario
+- Mis QRs
+- Solicitar QR para eventos ✅
+- Generar QR para promociones ✅
+
+## Fase 5: Admin - Gestión (✅ Completado)
+- [x] ABM Clubes (API ✅, vistas ✅)
+- [x] ABM Equipos (API ✅, vistas ✅)
+- [x] ABM Jugadores (API ✅, vistas ✅)
+- [x] ABM Eventos (API ✅, CRUD completo ✅)
+- [x] ABM Promociones/Lugares (✅)
+- [x] ABM Noticias (API ✅, vistas ✅)
+- [x] Escanear/validar QR (✅)
+
+## Fase 6: Pagos - Banco Macro (⏳ Pendiente - esperando credenciales)
+
+### 6.1 Configuración Inicial
+- [ ] Obtener credenciales de Banco Macro (CLIENT_ID, CLIENT_SECRET)
+- [ ] Configurar ambiente sandbox
+- [ ] Configurar webhook
+
+### 6.2 Modelado de Datos
+- [ ] Crear modelo ConceptoPago
+- [ ] Crear modelo Deuda
+- [ ] Extender modelo PagoMp existente
+
+### 6.3 Backend - API
+- [ ] CRUD conceptos de pago
+- [ ] CRUD deudas
+- [ ] Generación masiva de deudas (ej: cuota a todos)
+- [ ] Integración Botón Integrado Macro Click
+- [ ] Endpoint webhook para notificaciones
+- [ ] Endpoint validación de pagos
+
+### 6.4 Frontend - Panel Usuario
+- [ ] Sección "Mis Pagos" en panel usuario
+- [ ] Listado de deudas pendientes
+- [ ] Historial de pagos
+- [ ] Componente Botón Integrado (formulario embebido)
+- [ ] Estados de éxito/error del pago
+
+### 6.5 Panel Admin - Gestión de Pagos
+- [ ] Dashboard de deudas y cobros
+- [ ] Crear sanción individual a jugador
+- [ ] Generar cuotas masivas por club/equipo
+- [ ] Validación manual de pagos
+- [ ] Reportes (Excel/PDF)
+
+## Fase 7: Panel Usuario - QRs y Pagos (✅ Completado parcial)
+- [x] Solicitar QR para evento (desde detalle de evento)
+- [x] Generar QR tras solicitud (sin pago por ahora, Fase 6 pendiente)
+- [x] Visualizar mis QRs activos (con estado válido/usado)
+- [x] Generar QR para promociones (desde vista de promos)
+- [ ] Integrar generación de QR tras pago exitoso (depende de Fase 6)
+
+## Fase 8: Varias (✅ Completado parcial)
+- [x] Completar recuperar contraseña (vista reset + proceso completo)
+- [x] Fix formulario recuperar contraseña (HTML roto)
+- [ ] Envío de emails (deuda generada, pago confirmado) — depende de config SMTP
+- [x] Limpieza links admin (editar/eliminar desde detalle evento, promos admin)
+- [ ] Testing webhooks — depende de Fase 6
+
+---
+
+## Notas
+
+- **Fase 6 reemplaza MercadoPago** por Macro Click de Banco Macro
+- La integración será mediante **Botón Integrado** (pago dentro de la app)
+- El sistema permitirá autogestión de pagos por parte de los jugadores
+- **Reset password** incluye enlace de desarrollo directo (en producción, se enviaría por email)
diff --git a/misc/MANUAL_USUARIO.md b/misc/MANUAL_USUARIO.md
new file mode 100644
index 0000000..3d43190
--- /dev/null
+++ b/misc/MANUAL_USUARIO.md
@@ -0,0 +1,648 @@
+# 📖 Manual de Usuario — OnAPB
+### Sistema de Gestión de Asociación de Básquet
+> **Sitio:** [onapb.com](https://onapb.com)
+
+---
+
+## ¿A quién está dirigido este manual?
+
+OnAPB es utilizado por cuatro tipos de personas. Este manual explica paso a paso qué puede hacer cada una:
+
+| Capítulo | Perfil |
+|---|---|
+| [Capítulo 1](#cap1) | Visitantes (sin cuenta) |
+| [Capítulo 2](#cap2) | Jugadores federados |
+| [Capítulo 3](#cap3) | Aficionados / Hinchas |
+| [Capítulo 4](#cap4) | Administradores de Club |
+| [Capítulo 5](#cap5) | Súper Administradores (OnAPB) |
+
+---
+
+
+## 📌 Capítulo 1 — Visitante (Sin cuenta)
+
+Cualquier persona puede ingresar a **onapb.com** sin necesidad de registrarse y acceder a información pública de la liga.
+
+### 1.1 Página de Inicio (`/`)
+
+Al entrar al sitio el visitante encuentra:
+
+- **Carrusel / Hero**: Diapositivas destacadas configuradas por la asociación (noticias importantes, convocatorias, avisos).
+- **Próximos Partidos**: Listado de los eventos más cercanos con día, hora y equipos.
+- **Noticias recientes**: Artículos publicados por OnAPB.
+- **Sponsors**: Franja rotativa con los patrocinadores de la liga visible en el pie de página.
+
+### 1.2 Cartelera de Eventos (`/eventos`)
+
+- Ver todos los partidos programados: próximos, en curso y finalizados.
+- Cada tarjeta de evento muestra: equipos, categoría, fecha, hora y sede.
+- Hacer clic en un evento abre el **detalle del partido** con información completa.
+- Si el partido ya fue jugado, se muestran los marcadores finales.
+
+### 1.3 Tabla de Posiciones y Goleadores de Torneo
+
+- Desde la sección de torneos se puede ver:
+ - **Posiciones** por grupo o categoría.
+ - **Tabla de goleadores** del torneo con puntos acumulados.
+ - **Bracket de Playoffs**: diagrama del cuadro eliminatorio cuando corresponde.
+
+### 1.4 Noticias (`/noticias`)
+
+- Listado de artículos publicados por la asociación con imagen, título y texto completo.
+
+### 1.5 Promociones y Lugares con Beneficios (`/promos`)
+
+- Mapa y/o listado de locales comerciales asociados que ofrecen descuentos a miembros de OnAPB.
+- Ver información de cada comercio (nombre, dirección, beneficio).
+- Para **obtener el QR de descuento** es necesario tener una cuenta y estar logueado.
+
+### 1.6 Registrarse (`/asociate`)
+
+Para obtener todos los beneficios del sistema, el visitante puede crear una cuenta. Existen dos vías:
+
+#### Vía A — Registrarse como Aficionado
+1. Ir a `/asociate` y seleccionar la pestaña **"Soy Aficionado"**.
+2. Completar el formulario: Nombre, Apellido, DNI, Email, Fecha de Nacimiento (opcional), Teléfono (opcional), Localidad (opcional), Contraseña.
+3. Resolver el **captcha de seguridad** (Cloudflare Turnstile).
+4. Hacer clic en **Registrarme**.
+5. El sistema envía un **correo de bienvenida** a la dirección ingresada.
+6. ✅ Listo. Ya se puede iniciar sesión con DNI y contraseña.
+
+#### Vía B — Activar cuenta como Jugador Federado
+> *Para jugadores que ya fueron cargados en el sistema por el administrador de su club.*
+
+1. Ir a `/asociate` y seleccionar la pestaña **"Soy Jugador"**.
+2. Ingresar Nombre, Apellido y DNI (tal como aparecen en la ficha).
+3. Aceptar los términos y hacer clic en **Buscar**.
+4. El sistema valida los datos con el padrón. Si hay coincidencia, muestra la ficha del jugador (nombre, club, categoría).
+5. Completar el formulario de activación: Email, Teléfono (opcional), Contraseña.
+6. Resolver el **captcha de seguridad**.
+7. Hacer clic en **Activar mi cuenta**.
+8. El sistema envía un **correo de bienvenida**.
+9. ✅ Listo. La cuenta queda activa y se puede iniciar sesión.
+
+> **Nota:** Si el DNI no se encuentra en el padrón de jugadores, el sistema sugiere registrarse como Aficionado.
+
+### 1.7 Iniciar Sesión
+
+- Desde el menú superior hacer clic en **"Iniciar Sesión"** o ir a la pantalla de login.
+- Se muestra un formulario con dos pestañas:
+ - **Jugadores / Aficionados**: ingresar DNI y contraseña + captcha.
+ - **Administradores**: ingresar usuario y contraseña + captcha.
+
+### 1.8 Recuperar Contraseña (`/recuperar`)
+
+En caso de olvidar la contraseña:
+1. Ir a `/recuperar`.
+2. Ingresar el **DNI** y la **dirección de email** asociados a la cuenta.
+3. El sistema envía un enlace de restablecimiento válido por **1 hora**.
+4. Hacer clic en el enlace del correo y establecer la nueva contraseña (mínimo 6 caracteres, requiere confirmación).
+
+---
+
+
+## 🏅 Capítulo 2 — Jugador Federado
+
+Un jugador que ya activó su cuenta (Vía B de `/asociate`) tiene acceso a un **Panel de Usuario** con funcionalidades específicas para deportistas federados.
+
+### 2.1 Iniciar Sesión
+
+- En la pantalla de login, pestaña **"Jugadores / Aficionados"**.
+- Ingresar **DNI** y **contraseña**.
+- El sistema detecta automáticamente si la cuenta corresponde a un Jugador o Aficionado.
+
+### 2.2 Panel de Usuario (`/panel-usuario`)
+
+Al acceder al panel, el jugador ve de un vistazo:
+
+- Sus **datos personales**: nombre, DNI, club, categoría calculada automáticamente por edad.
+- Sus **equipos asignados** (puede pertenecer a varios equipos dentro de su club).
+- **Resumen de QRs**: cantidad de pases de eventos solicitados.
+- **Notificaciones** del sistema (ícono en el menú superior con contador).
+
+### 2.3 Solicitar QR para un Partido
+
+Esta es la funcionalidad principal del jugador federado. Permite obtener un **código QR de acceso** a un evento.
+
+**Pasos:**
+1. Desde el menú público o desde el panel, ir a **Eventos** y buscar el partido deseado.
+2. Entrar al **detalle del partido**.
+3. Si el partido está en estado *Próximo* o *En Curso*, aparece el botón **"Solicitar QR"**.
+4. Hacer clic en el botón. El sistema verifica automáticamente:
+ - Si el jugador **pertenece a uno de los equipos** del partido → genera la cantidad de QRs configurada (por defecto 3, para repartir entre familiares/acompañantes). Tipo: `invitado`.
+ - Si el jugador es de **categoría Libre** pero no juega ese partido → genera 1 QR con descuento del 50%. Tipo: `libre_50`.
+ - Si el jugador no cumple ninguna condición → el botón no estará disponible o muestra un mensaje explicativo.
+5. ✅ El sistema genera los QRs y envía un **correo con los códigos** al email registrado.
+6. El jugador es redirigido automáticamente a **Mis QRs** para ver los códigos generados.
+
+> **Restricción:** Solo se puede solicitar QR una vez por partido. Si ya se solicitó, el botón queda inhabilitado con un mensaje informativo.
+
+### 2.4 Mis QRs (`/panel-usuario/mis-qrs`)
+
+- Lista de todos los códigos QR del jugador, ordenados del más reciente al más antiguo.
+- Cada QR muestra:
+ - Imagen del código QR (escaneable)
+ - Evento al que pertenece (equipos, fecha, hora)
+ - Estado: **Válido** / **Usado** (según si fue escaneado en puerta)
+ - Tipo de QR (invitado, libre_50, etc.)
+- Se puede filtrar por evento específico.
+
+### 2.5 Beneficios de Promociones
+
+1. Ir a **Promos** desde el menú principal.
+2. Ver el mapa/listado de locales con beneficios.
+3. Hacer clic en **"Obtener mi QR de beneficio"** en el local deseado.
+4. El sistema genera un **QR de descuento único** para ese local.
+5. Ver el QR en pantalla para presentarlo en el comercio.
+
+> **Restricción:** Solo se puede generar 1 QR por local por usuario.
+
+### 2.6 Seguir Equipos
+
+- Desde la página pública de un equipo (`/equipos/{id}`), hacer clic en **"Seguir"**.
+- Acceder a **Mis Equipos Seguidos** desde el Panel para ver el historial de partidos de los equipos favoritos.
+- Hacer clic nuevamente en "Seguir" en un equipo ya seguido lo deja de seguir (toggle).
+
+### 2.7 Notificaciones (`/notificaciones`)
+
+- El ícono de campana en el menú muestra la cantidad de notificaciones no leídas.
+- Las notificaciones pueden ser generadas por el sistema automáticamente (ej: "Tus QRs para el partido del sábado están disponibles").
+- Desde el centro de notificaciones se puede:
+ - **Marcar como leída** una notificación individual.
+ - **Marcar todas como leídas**.
+ - **Eliminar** notificaciones individuales.
+ - **Eliminar todas** las notificaciones.
+
+### 2.8 Editar Datos Personales
+
+Desde el Panel de Usuario, sección **"Mi Cuenta"**:
+- Actualizar **email** y **teléfono**.
+- Los jugadores no pueden modificar su nombre, DNI o fecha de nacimiento (esos datos son gestionados por el admin del club).
+
+### 2.9 Cambiar Contraseña
+
+Desde el Panel de Usuario, sección **"Seguridad"**:
+1. Ingresar la contraseña actual.
+2. Ingresar la nueva contraseña (mínimo 6 caracteres).
+3. Confirmar la nueva contraseña.
+4. Guardar cambios.
+
+### 2.10 Cerrar Sesión
+
+- Hacer clic en **"Cerrar Sesión"** en el menú (esquina superior derecha o menú hamburguesa en móvil).
+- La sesión se cierra de forma segura.
+
+---
+
+
+## 🎉 Capítulo 3 — Aficionado / Hincha
+
+El aficionado tiene las mismas capacidades que el jugador en lo que respecta a disfrute de la plataforma, con algunas diferencias en la lógica de los QRs.
+
+### 3.1 Inicio de Sesión y Registro
+
+Idéntico al Jugador: login con DNI + contraseña. El registro es mediante la **Vía A** descripta en el Capítulo 1.
+
+### 3.2 Panel de Usuario
+
+Igual al del Jugador, con las siguientes diferencias visibles:
+- **No se muestra** información de club ni categoría federada.
+- El campo **"Localidad"** sí es editable (además de email y teléfono).
+
+### 3.3 Solicitar QR para un Partido
+
+El aficionado puede solicitar 1 QR por partido. A diferencia del jugador federado:
+- No hay distinción por equipo ni categoría.
+- Se genera 1 QR de tipo `publico`.
+- En el futuro (Fase 6), este QR estará sujeto al pago de la entrada; actualmente se genera de forma directa.
+
+El proceso es idéntico a los pasos 1–5 del apartado 2.3.
+
+### 3.4 Mis QRs, Promociones, Notificaciones y Cuenta
+
+Funciona exactamente igual que para el Jugador (ver apartados 2.4 al 2.10).
+
+---
+
+
+## 🏢 Capítulo 4 — Administrador de Club
+
+El Admin de Club es el responsable designado de gestionar su institución dentro de OnAPB. Accede al panel de administración pero con alcance limitado exclusivamente a los datos de su club.
+
+### 4.1 Inicio de Sesión
+
+- En la pantalla de login, pestaña **"Administradores"**.
+- Ingresar **usuario** (asignado por un Súper Admin) y **contraseña**.
+
+### 4.2 Dashboard del Admin de Club (`/admin`)
+
+Al ingresar, el Admin de Club ve:
+- **Estadísticas rápidas** de su club: cantidad de equipos, jugadores y eventos relacionados.
+- Nombre e identificación de su club.
+- Accesos rápidos a los módulos disponibles.
+
+### 4.3 Gestión de Jugadores (`/admin/jugadores`)
+
+#### Ver listado de jugadores
+- Lista de todos los jugadores pertenecientes a su club.
+- Búsqueda por nombre, apellido o DNI.
+- Cada jugador muestra: nombre, DNI, fecha de nacimiento, categoría (calculada automáticamente) y estado (activo/inactivo).
+
+#### Crear un nuevo jugador
+1. Hacer clic en **"Nuevo Jugador"**.
+2. Completar el formulario:
+ - DNI, Nombre, Apellido, Fecha de Nacimiento.
+ - Club de Origen (puede ser cualquier club del sistema — útil para jugadores con pase).
+ - El campo "Club Actual" se asigna automáticamente al club del administrador.
+3. Guardar.
+4. El jugador se crea con estado **inactivo**. Necesitará completar su registro en `/asociate` para activar su cuenta.
+
+> **Validación:** Si el DNI ya existe en el sistema, se muestra un error indicando a qué club pertenece el jugador actualmente.
+
+#### Editar un jugador
+- Modificar datos básicos (nombre, apellido, fecha de nacimiento, teléfono).
+- No puede cambiar el club actual del jugador (eso requiere un pase gestionado por el SuperAdmin).
+
+#### Eliminar un jugador
+- El jugador se marca como eliminado (SoftDelete). No se borra físicamente.
+
+#### Importar jugadores desde CSV
+1. Hacer clic en **"Importar CSV"**.
+2. Subir un archivo CSV en formato CAB (Argentina Basketball) o formato interno.
+3. El sistema detecta automáticamente el formato y procesa cada fila.
+4. Al finalizar, muestra un resumen: nuevos creados, omitidos (ya existían), errores.
+
+#### Exportar jugadores a CSV
+- Descargar el listado completo de jugadores del club en formato CSV.
+- Compatible con el reimportador interno del sistema.
+
+### 4.4 Gestión de Equipos (`/admin/equipos`)
+
+#### Ver listado de equipos
+- Lista de los equipos del club con cantidad de jugadores.
+
+#### Crear un nuevo equipo
+1. Hacer clic en **"Nuevo Equipo"**.
+2. Seleccionar **Categoría** (ej: U13, U15, U17, Primera) y Division (A, B, etc.).
+3. El club se asigna automáticamente.
+
+#### Editar y eliminar equipos
+- Modificar categoría y división.
+- Eliminar (SoftDelete).
+
+#### Gestionar jugadores del equipo
+1. Desde el listado de equipos, hacer clic en el ícono de jugadores (**"Ver Plantel"**).
+2. Ver la lista de jugadores asignados a ese equipo.
+3. **Agregar jugador**: buscar por nombre/apellido/DNI (búsqueda en tiempo real) y hacer clic en "Agregar".
+ - Solo se pueden agregar jugadores del mismo club.
+ - El sistema previene la asignación duplicada.
+4. **Remover jugador**: hacer clic en "Quitar" al lado del jugador.
+
+### 4.5 Editar Identidad Visual del Club (`/admin/clubes/{id}/editar`)
+
+El Admin de Club puede personalizar la apariencia de su club dentro del sistema:
+
+- **Logo/Escudo**: subir una imagen (JPEG, PNG, WEBP, máx. 1MB) que aparecerá asociada al club en toda la plataforma.
+- **Fondo de QR** (QR Background): subir una imagen que se usará como fondo decorativo en los códigos QR generados para los partidos de su club.
+- **Color de texto del QR**: ingresar un color hexadecimal para personalizar la tipografía sobre el QR.
+
+### 4.6 Gestión de Pases / Traspasos (`/admin/pases`)
+
+Los pases son solicitudes formales para transferir un jugador de un club a otro.
+
+#### Solicitar un pase
+1. Ir a **Pases** y hacer clic en **"Nuevo Pase"**.
+2. Seleccionar el jugador a transferir y el club de destino.
+3. Enviar la solicitud.
+4. El Súper Admin recibirá la solicitud para aprobarla o rechazarla.
+
+#### Ver estado de pases
+- Listado de todos los pases solicitados por el club.
+- Estado posible: **Pendiente**, **Aprobado**, **Rechazado**.
+
+### 4.7 Escanear QR en Eventos (`/admin/escanear-qr`)
+
+Esta funcionalidad es para usar en la puerta del evento el día del partido.
+
+1. Ir a **"Escanear QR"** en el menú.
+2. Seleccionar el **evento** (partido) a gestionar. Solo aparecen eventos relacionados con su club.
+3. Activar la **cámara** del dispositivo o ingresar manualmente el código del QR.
+4. El sistema valida el QR en tiempo real:
+ - ✅ **QR Válido**: muestra nombre del titular, tipo de QR y pasa el estado a "Usado".
+ - ❌ **QR Inválido o Ya Usado**: muestra el error correspondiente.
+
+### 4.8 Cerrar Sesión
+
+- Hacer clic en **"Cerrar Sesión"** en el menú del panel administrador.
+
+---
+
+
+## 👑 Capítulo 5 — Súper Administrador (OnAPB)
+
+El Súper Admin tiene control total sobre todas las entidades del sistema. Es el personal de la asociación OnAPB.
+
+### 5.1 Inicio de Sesión
+
+Igual que el Admin de Club: pestaña "Administradores", usuario y contraseña.
+
+### 5.2 Dashboard del Súper Admin (`/admin`)
+
+Al ingresar, el Súper Admin ve:
+- **Estadísticas globales**: total de clubes, equipos, jugadores, eventos, promociones y noticias en todo el sistema.
+- Accesos rápidos a todos los módulos.
+
+---
+
+### 5.3 Módulo de Clubes (`/admin/clubes`)
+
+#### Ver todos los clubes
+- Lista de todos los clubes registrados con conteo de equipos y jugadores.
+
+#### Crear un club
+1. Clic en **"Nuevo Club"**.
+2. Ingresar ID Club (numérico) y Nombre.
+3. Guardar.
+
+#### Editar un club
+- Modificar nombre, logo, fondo de QR y color de texto. (Igual que el Admin de Club pero con acceso a cambiar el nombre también).
+
+#### Eliminar un club
+- El club se marca como eliminado (SoftDelete). Los equipos y jugadores asociados quedan preservados.
+
+---
+
+### 5.4 Módulo de Equipos (`/admin/equipos`)
+
+Idéntico al del Admin de Club pero con visión global (todos los clubes). Al crear un equipo, el Súper Admin puede seleccionar cualquier club del sistema.
+
+---
+
+### 5.5 Módulo de Jugadores (`/admin/jugadores`)
+
+#### Ver todos los jugadores
+- Lista global de todos los jugadores de todos los clubes.
+- Filtro por nombre, apellido o DNI.
+- Paginación de 25 registros por página.
+
+#### Crear, editar, eliminar jugador
+- Idéntico al Admin de Club pero con acceso a cambiar el **Club Actual** (útil para finiquitar traspasos).
+
+#### Importar / Exportar CSV
+- Importación masiva de jugadores en formato CAB, Interno o Legado.
+- Importar para un club específico (seleccionado en el formulario).
+
+---
+
+### 5.6 Módulo de Eventos / Partidos (`/admin/eventos`)
+
+#### Ver todos los eventos
+- Lista de todos los partidos con filtros por estado: Próximos, En Curso, Finalizados.
+
+#### Crear un nuevo partido
+1. Clic en **"Nuevo Evento"**.
+2. Completar:
+ - **Nombre del evento** (generado automáticamente en base a equipos).
+ - **Equipo Local** y **Equipo Visitante** (deben ser de la misma categoría y grupo si pertenecen a un torneo).
+ - **Fecha**, **Hora de Inicio** y **Hora de Fin**.
+ - **Sede**.
+ - **Torneo** (opcional): asignar el evento a un torneo existente.
+ - **Límite de QRs por jugador** (configurable).
+3. Guardar. El evento queda en estado **"Próximo"**.
+
+#### Editar un partido
+- Modificar cualquier campo del evento.
+
+#### Registrar resultados
+1. Desde el listado de eventos, entrar al evento.
+2. Ir a la sección **"Cargar Estadísticas"** o **"Resultado"**.
+3. Ingresar marcador local y marcador visitante.
+4. Registrar puntos individuales por jugador (para la tabla de goleadores).
+5. Guardar. El evento cambia automáticamente a estado **"Finalizado"**.
+
+#### Eliminar un evento
+- El evento se marca como eliminado (SoftDelete). Los QRs asociados se limpian.
+
+---
+
+### 5.7 Módulo de Torneos (`/admin/torneos`)
+
+#### Crear un torneo
+1. Clic en **"Nuevo Torneo"**.
+2. Ingresar nombre y año.
+3. Guardar.
+
+#### Añadir equipos al torneo
+Desde la vista del torneo:
+1. Buscar el equipo a agregar.
+2. Asignarle un **grupo** (ej: Grupo A, Grupo B) si aplica.
+3. Guardar.
+
+#### Generar Fixture de Fase Regular
+1. Desde el torneo, ir a **"Generar Fixture"**.
+2. Configurar la cantidad de vueltas (ida, ida y vuelta).
+3. El sistema genera un **preview** de todos los partidos a jugarse.
+4. Confirmar para crear los eventos en el sistema.
+
+#### Importar resultados históricos
+- Subir un CSV con resultados de partidos ya jugados para cargar históricos de torneos anteriores.
+
+#### Gestionar Playoffs
+1. Al finalizar la fase regular, ir a **"Playoffs"**.
+2. Configurar cuántos equipos clasifican a playoffs.
+3. Generar el bracket eliminatorio.
+4. A medida que se juegan los partidos, **"Avanzar ganador"** al siguiente cruce.
+
+#### Ver posiciones y goleadores
+- Desde `/torneos/{id}/posiciones`: tabla de posiciones filtrable por grupo/categoría.
+- Desde `/torneos/{id}/goleadores`: ranking de anotadores.
+- Desde `/torneos/{id}/playoffs`: bracket visual del cuadro eliminatorio.
+
+---
+
+### 5.8 Módulo de Pases / Traspasos (`/admin/pases`)
+
+#### Ver todos los pases
+- Lista de todas las solicitudes de traspaso pendientes, aprobadas y rechazadas.
+
+#### Aprobar o rechazar un pase
+1. Desde el listado, hacer clic en el pase pendiente.
+2. Revisar los datos: jugador, club origen, club destino.
+3. Hacer clic en **"Aprobar"** → el jugador cambia de club automáticamente.
+4. O hacer clic en **"Rechazar"** → el pase queda como rechazado y el jugador permanece en su club actual.
+
+#### Crear un pase directamente
+El Súper Admin puede ejecutar una transferencia sin necesidad de solicitud previa, seleccionando jugador, club destino y guardando.
+
+---
+
+### 5.9 Módulo de Noticias (`/admin/noticias`)
+
+#### Crear una noticia
+1. Clic en **"Nueva Noticia"**.
+2. Completar: Título, Contenido (texto largo), Imagen (opcional).
+3. Guardar. La noticia aparece en `/noticias`.
+
+#### Editar y eliminar noticias
+- Actualizar cualquier campo.
+- Eliminar elimina la noticia del sitio público.
+
+---
+
+### 5.10 Módulo de Promociones (`/admin/promociones`)
+
+#### Crear una promoción / local con beneficio
+1. Clic en **"Nueva Promoción"**.
+2. Completar: Nombre del local, Descripción del beneficio, Dirección, Imagen, Coordenadas (para el mapa).
+3. Guardar. El local aparece en `/promos`.
+
+#### Editar y eliminar promociones
+- Actualizar información del local.
+- Eliminar la promoción del sistema.
+
+---
+
+### 5.11 Módulo de Carrusel / Hero (`/admin/carousel`)
+
+El carrusel es la sección de diapositivas destacadas en la portada del sitio.
+
+#### Agregar una diapositiva
+1. Clic en **"Nueva Diapositiva"**.
+2. Subir una imagen (recomendado: formato ancho/horizontal).
+3. Completar un enlace opcional (ej: enlace a una noticia o evento).
+4. Guardar.
+
+#### Editar y eliminar diapositivas
+- Actualizar imagen o enlace.
+- Eliminar la diapositiva del carrusel.
+
+---
+
+### 5.12 Módulo de Sponsors (`/admin/sponsors`)
+
+#### Agregar un sponsor
+1. Clic en **"Nuevo Sponsor"**.
+2. Subir el logo del patrocinador.
+3. Ingresar URL del sitio del sponsor (opcional).
+4. Guardar. El logo aparece en la franja rotativa del pie de página.
+
+#### Editar y eliminar sponsors
+- Reemplazar logo o cambiar URL.
+- Eliminar el sponsor de la franja.
+
+---
+
+### 5.13 Módulo de Usuarios Administradores (`/admin/usuarios`)
+
+#### Ver todos los administradores
+- Lista de todos los usuarios con acceso al panel de administración.
+
+#### Crear un nuevo administrador
+1. Clic en **"Nuevo Usuario Admin"**.
+2. Ingresar:
+ - **Usuario** (nombre de login).
+ - **Contraseña**.
+ - **Rol**: `1 = Súper Admin` o `2 = Admin de Club`.
+ - **Club** (solo si rol 2): asociar al club que administrará.
+3. Guardar.
+
+#### Editar y eliminar administradores
+- Cambiar contraseña, rol o club asignado.
+- Eliminar el acceso de un administrador.
+
+---
+
+### 5.14 Módulo de Categorías (`/admin/categorias`)
+
+Las categorías definen los rangos de edad de los jugadores (ej: U13 = 12-13 años).
+
+#### Crear una categoría
+1. Ingresar nombre (ej: "U13"), edad mínima (ej: 12) y edad máxima (ej: 13).
+2. Marcar si es **"Categoría Libre"** (los jugadores de esta categoría obtienen 50% de descuento en entradas).
+3. Guardar.
+
+#### Editar y eliminar categorías
+- Las categorías modificadas se aplican automáticamente a todos los jugadores ya existentes (la categoría se calcula dinámicamente).
+
+---
+
+### 5.15 Escanear QR en Eventos (`/admin/escanear-qr`)
+
+Idéntico al del Admin de Club, pero con visión de todos los eventos del sistema. Ver apartado 4.7 para el proceso detallado.
+
+---
+
+### 5.16 Configuración General (`/admin/configuracion`)
+
+Ajustes globales del sistema:
+- Frecuencia de backups automáticos.
+- Otros parámetros de comportamiento del sistema.
+
+---
+
+### 5.17 OnAPB Genius — Asistente de IA
+
+El portal incluye un asistente conversacional basado en IA (Google Gemini) que permite consultar y operar el sistema mediante lenguaje natural. Se invoca desde el botón flotante del chat.
+
+**Quién puede usar qué:**
+
+| Rol | Tools disponibles |
+|---|---|
+| Visitante / Aficionado / Jugador | Chat de ayuda general del portal (sin acceso a operaciones) |
+| Administrador de Club | Solo tools de **lectura** |
+| Súper Administrador | Lectura + **escritura** + **rollback** |
+
+**Tools disponibles para Súper Admin:**
+
+**Lectura (consulta de datos):**
+- `listar_torneos` — Lista todos los torneos con ID, nombre y fechas. Usala cuando necesites buscar el ID de un torneo por su nombre.
+- `listar_equipos` — Lista equipos. Filtros opcionales: `id_torneo`, `grupo` ("A", "B", ...).
+- `listar_eventos` — Lista partidos. Filtros opcionales: `fecha_desde`, `fecha_hasta` (formato YYYY-MM-DD), `id_torneo`.
+
+**Escritura (modifican la base de datos — requieren tu confirmación explícita):**
+- `crear_partido` — Crea un nuevo partido. Campos: `id_equipo_local`, `id_equipo_visitante`, `fecha_evento`, `hora_inicio`, `hora_fin`, `sede`, `id_torneo`.
+- `cargar_puntaje` — Actualiza el marcador de un partido existente. Campos: `id_evento` (UUID), `marcador_local`, `marcador_visitante`.
+- `redactar_noticia` — Publica una noticia. Campos: `titulo`, `contenido`, `id_torneo` (opcional), `categoria` (opcional).
+
+**Rollback (deshacer una creación):**
+- `eliminar_noticia` — Borra una noticia por su `id_noticia` (numérico).
+- `eliminar_partido` — Borra (soft delete) un partido por su `id_evento` (UUID).
+
+**Cómo se usa (flujo típico):**
+
+1. Escribí en lenguaje natural lo que querés ("creá una noticia sobre el partido del sábado", "qué equipos hay en el torneo Apertura", "cargá el marcador del partido de ayer 78 a 65").
+2. Si pedís una acción de escritura, Genius **primero te muestra un resumen** con los datos a usar y te pregunta "¿Confirmás?". Tenés que responder "sí", "dale", "confirmo" u "ok" para que ejecute.
+3. Tras crear algo, Genius te devuelve el ID del recurso. Guardalo por si querés revertir.
+4. Para deshacer: pedile "eliminá la noticia ID X" o "borrá el partido que acabás de crear". Volverá a pedir confirmación antes de borrar.
+
+**Buenas prácticas:**
+
+- Si mencionás un torneo/equipo por nombre, Genius usa `listar_torneos` / `listar_equipos` para resolver el ID. No hace falta que lo sepas de memoria.
+- Verificá siempre el resumen de confirmación antes de responder "sí". La IA puede interpretar mal fechas o nombres ambiguos.
+- Si algo sale mal, revisá `storage/logs/laravel.log` donde quedan registrados los errores de las tools.
+- Los límites de la cuenta gratuita de Gemini están configurados en `.env` (`GENIUS_MAX_MESSAGES_PER_SESSION`, `GENIUS_SESSION_WINDOW_MINUTES`). Podés ajustarlos si cambia el uso.
+
+---
+
+## ❓ Preguntas Frecuentes
+
+**¿Cómo sé si ya tengo una cuenta de jugador?**
+Si sos jugador federado, tu DNI ya está en el sistema cargado por el admin de tu club. Solo debés ir a `/asociate` → pestaña "Soy Jugador" e ingresar tus datos para activar la cuenta.
+
+**¿Puedo tener cuenta de jugador Y de aficionado con el mismo DNI?**
+No. El sistema detecta si ya existe un registro (como jugador o aficionado) y te informa antes de permitir el registro.
+
+**¿Qué pasa si el QR que generé fue escaneado?**
+El estado del QR cambia a "Usado" y no puede ser utilizado nuevamente. Cada QR tiene un solo uso.
+
+**¿Puedo generar el QR de una promo más de una vez?**
+No. Por cada usuario y cada promoción, solo se puede generar un QR de beneficio.
+
+**¿Qué significa cada tipo de QR?**
+- `invitado` → Jugador que pertenece a un equipo del partido. Puede generar múltiples (para acompañantes).
+- `libre_50` → Jugador de categoría Libre. Obtiene descuento del 50% en la entrada.
+- `publico` → Aficionado. Acceso estándar.
+
+---
+
+*Manual redactado para el Proyecto Integrador OnAPB — Taller de Integración, FCYT UADER 2026.*
diff --git a/misc/OnAPB - Manual de Usuario.pdf b/misc/OnAPB - Manual de Usuario.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..76ffe7045a5e600b029eb4df776ece0070175236
GIT binary patch
literal 758569
zcmaHSWmH>T)F$p)DDLi1pm=e2r?|UoDFuRiad(Q9;!>nI6nA%bD^{!|xs!Kh*81kx
z{JTl6oa}whKKqf&s3|MY`HqVpgK^>FY!Bld4G)dGl@o@T7`LvYkBco0i-wz&rZf#F
zjhdyKucZr(jV+Cyx38s_qdO}Gx2Bi7jjy$>7Y&P!v!f+9>{c|c@A!pi6x`kIVb{vK
zTYJ-J+q&7 =%-tbd{Omx>K_cO
z8dpUF1lP~xmR%Y_721w`q~5}9@j?E9xolP?98&w^G0$w`R&2z@(Z5-KGmAQ`qjwgz
z^lTXidyQ `p3DoTlFZQ orSSkMa6%b%i^Kvw!V9``Gwl*_yMPX5OGj{zmN5;;`!VHB)
z!^+gvl7jss7Yd8CnU#g5>tAUx2U`bcRYxNe$h+cZ?p7vdQqD$RC@i8@t}aSu&SDOB
zjt=%__O28>C@hjzwytK*6fBaqMy_V!W+o1%5V-PY_7)JpY^>Zo>_S2m{~*sZbN8*D
zwA$(&DHBPMml7K`7G;Q`FH-OY4K}dT*b(F1n8a(@<0QVB_Qggs5?Eyr&$=o6Jdp