diff --git a/app/AI/Prompts/SystemPromptAdmin.php b/app/AI/Prompts/SystemPromptAdmin.php new file mode 100644 index 0000000..199f942 --- /dev/null +++ b/app/AI/Prompts/SystemPromptAdmin.php @@ -0,0 +1,72 @@ +'); + $faqPos = strpos($content, '## ❓ Preguntas Frecuentes'); + + if ($cap4Pos !== false && $faqPos !== false && $faqPos > $cap4Pos) { + $content = substr($content, 0, $cap4Pos) . substr($content, $faqPos); + } + + return $content; + } +} diff --git a/app/AI/Tools/CargarPuntajeTool.php b/app/AI/Tools/CargarPuntajeTool.php new file mode 100644 index 0000000..cb54d13 --- /dev/null +++ b/app/AI/Tools/CargarPuntajeTool.php @@ -0,0 +1,30 @@ + "No se encontró el partido con ID: {$id_evento}"]); + } + + $evento->update([ + 'marcador_local' => $marcador_local, + 'marcador_visitante' => $marcador_visitante, + ]); + + return json_encode([ + 'success' => true, + 'mensaje' => "Puntaje actualizado: {$marcador_local} - {$marcador_visitante}", + ]); + } +} diff --git a/app/AI/Tools/CrearPartidoTool.php b/app/AI/Tools/CrearPartidoTool.php new file mode 100644 index 0000000..1b94e5b --- /dev/null +++ b/app/AI/Tools/CrearPartidoTool.php @@ -0,0 +1,43 @@ + (string) Str::uuid(), + 'id_equipo_local' => $id_equipo_local, + 'id_equipo_visitante' => $id_equipo_visitante, + 'fecha_evento' => $fecha_evento, + 'hora_inicio' => str_contains($hora_inicio, ':') ? $hora_inicio . (strlen($hora_inicio) === 5 ? ':00' : '') : $hora_inicio, + 'hora_fin' => str_contains($hora_fin, ':') ? $hora_fin . (strlen($hora_fin) === 5 ? ':00' : '') : $hora_fin, + 'sede' => $sede, + 'id_torneo' => $id_torneo, + 'precio' => $precio ?? 0, + 'fase' => Evento::FASE_REGULAR, + ]); + + return json_encode([ + 'success' => true, + 'id_evento' => $evento->id_evento, + 'mensaje' => "Partido creado correctamente. ID: {$evento->id_evento}", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo crear el partido: ' . $e->getMessage()]); + } + } +} diff --git a/app/AI/Tools/EliminarNoticiaTool.php b/app/AI/Tools/EliminarNoticiaTool.php new file mode 100644 index 0000000..0497111 --- /dev/null +++ b/app/AI/Tools/EliminarNoticiaTool.php @@ -0,0 +1,29 @@ + "No se encontró la noticia con ID: {$id_noticia}"]); + } + + $titulo = $noticia->titulo; + $noticia->delete(); + + return json_encode([ + 'success' => true, + 'mensaje' => "Noticia \"{$titulo}\" (ID {$id_noticia}) eliminada correctamente.", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo eliminar la noticia: ' . $e->getMessage()]); + } + } +} diff --git a/app/AI/Tools/EliminarPartidoTool.php b/app/AI/Tools/EliminarPartidoTool.php new file mode 100644 index 0000000..0308804 --- /dev/null +++ b/app/AI/Tools/EliminarPartidoTool.php @@ -0,0 +1,28 @@ + "No se encontró el partido con ID: {$id_evento}"]); + } + + $evento->delete(); + + return json_encode([ + 'success' => true, + 'mensaje' => "Partido {$id_evento} eliminado correctamente (soft delete).", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo eliminar el partido: ' . $e->getMessage()]); + } + } +} diff --git a/app/AI/Tools/ListarEquiposTool.php b/app/AI/Tools/ListarEquiposTool.php new file mode 100644 index 0000000..3ab9def --- /dev/null +++ b/app/AI/Tools/ListarEquiposTool.php @@ -0,0 +1,33 @@ +join('torneo_equipo', 'equipos.id_equipo', '=', 'torneo_equipo.id_equipo') + ->where('torneo_equipo.id_torneo', $id_torneo); + + if ($grupo !== null) { + $query->where('torneo_equipo.grupo', $grupo); + } + + $query->select('equipos.id_equipo', 'equipos.categoria', 'equipos.division', 'equipos.id_club'); + } + + $equipos = $query->get()->map(fn($e) => [ + 'id_equipo' => $e->id_equipo, + 'categoria' => $e->categoria, + 'division' => $e->division, + 'club' => $e->club?->nombre, + ]); + + return json_encode($equipos); + } +} diff --git a/app/AI/Tools/ListarEventosTool.php b/app/AI/Tools/ListarEventosTool.php new file mode 100644 index 0000000..fb6aa72 --- /dev/null +++ b/app/AI/Tools/ListarEventosTool.php @@ -0,0 +1,40 @@ +whereNull('deleted_at'); + + if ($fecha_desde) { + $query->whereDate('fecha_evento', '>=', $fecha_desde); + } + if ($fecha_hasta) { + $query->whereDate('fecha_evento', '<=', $fecha_hasta); + } + if ($id_torneo) { + $query->where('id_torneo', $id_torneo); + } + + $eventos = $query->orderBy('fecha_evento')->get()->map(fn($e) => [ + 'id_evento' => $e->id_evento, + 'fecha' => $e->fecha_evento?->format('Y-m-d'), + 'hora' => $e->hora_inicio?->format('H:i'), + 'local' => $e->equipoLocal?->club?->nombre, + 'visitante' => $e->equipoVisitante?->club?->nombre, + 'marcador_local' => $e->marcador_local, + 'marcador_visitante' => $e->marcador_visitante, + 'sede' => $e->sede, + ]); + + return json_encode($eventos); + } +} diff --git a/app/AI/Tools/ListarTorneosTool.php b/app/AI/Tools/ListarTorneosTool.php new file mode 100644 index 0000000..a1b8f0e --- /dev/null +++ b/app/AI/Tools/ListarTorneosTool.php @@ -0,0 +1,20 @@ +get()->map(fn ($t) => [ + 'id_torneo' => $t->id, + 'nombre' => $t->nombre, + 'fecha_inicio' => $t->fecha_inicio?->format('Y-m-d'), + 'fecha_fin' => $t->fecha_fin?->format('Y-m-d'), + ]); + + return json_encode($torneos); + } +} diff --git a/app/AI/Tools/RedactarNoticiaTool.php b/app/AI/Tools/RedactarNoticiaTool.php new file mode 100644 index 0000000..271ad01 --- /dev/null +++ b/app/AI/Tools/RedactarNoticiaTool.php @@ -0,0 +1,33 @@ + $titulo, + 'contenido' => $contenido, + 'fecha' => now(), + 'id_torneo' => $id_torneo, + 'categoria' => $categoria, + ]); + + return json_encode([ + 'success' => true, + 'id_noticia' => $noticia->id, + 'mensaje' => "Noticia \"{$titulo}\" creada correctamente.", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo crear la noticia: ' . $e->getMessage()]); + } + } +} diff --git a/app/Console/Commands/CleanupOldEvents.php b/app/Console/Commands/CleanupOldEvents.php new file mode 100644 index 0000000..6c0d370 --- /dev/null +++ b/app/Console/Commands/CleanupOldEvents.php @@ -0,0 +1,51 @@ +subDays((int)$dias); + + $eventosAEliminar = \App\Models\Evento::withTrashed() + ->where('fecha_evento', '<', $fechaLimite->toDateString()) + ->get(); + $total = $eventosAEliminar->count(); + + if ($total === 0) { + $this->info("No hay eventos antiguos para eliminar."); + return; + } + + foreach ($eventosAEliminar as $evento) { + /** @var \App\Models\Evento $evento */ + // Eliminar QRs asociados + $evento->qrCodes()->delete(); + // Ya no eliminamos el evento para mantener registro de puntos y goleadores + // $evento->delete(); + } + + $this->info("Se han limpiado los QRs de $total eventos antiguos (Antigüedad > $dias días). Los eventos permanecen en el sistema."); + } +} diff --git a/app/Console/Commands/OptimizeImages.php b/app/Console/Commands/OptimizeImages.php new file mode 100644 index 0000000..d2a430c --- /dev/null +++ b/app/Console/Commands/OptimizeImages.php @@ -0,0 +1,196 @@ + ['maxWidth' => 1600, 'quality' => 82], + 'noticias' => ['maxWidth' => 1200, 'quality' => 82], + 'promos' => ['maxWidth' => 1200, 'quality' => 82], + 'clubes' => ['maxWidth' => 512, 'quality' => 85], + 'sponsors' => ['maxWidth' => 600, 'quality' => 85], + 'qr' => ['maxWidth' => 800, 'quality' => 85], + ]; + + public function handle(): int + { + $apply = (bool) $this->option('apply'); + $only = $this->option('folder'); + $noBackup = (bool) $this->option('no-backup'); + + if (!extension_loaded('gd')) { + $this->error('La extension GD de PHP no esta instalada.'); + return self::FAILURE; + } + + // Usa la config real del disk 'public' (respeta override de Hostinger) + $base = realpath(config('filesystems.disks.public.root')) ?: config('filesystems.disks.public.root'); + if (!is_dir($base)) { + $this->error("No existe: $base"); + return self::FAILURE; + } + $this->line("Base: $base"); + + $backupBase = $base . '/_backup_optimize'; + if ($apply && !$noBackup && !is_dir($backupBase)) { + mkdir($backupBase, 0755, true); + } + + $this->line(''); + $this->line($apply + ? 'MODO APLICACION — los archivos seran reemplazados.' + : 'MODO DRY-RUN — no se modifica nada. Pasa --apply para aplicar.'); + $this->line(''); + + $totalOrig = 0; $totalNew = 0; $totalProcessed = 0; $totalSkipped = 0; + + foreach ($this->config as $folder => $cfg) { + if ($only && $only !== $folder) continue; + + $path = $base . '/' . $folder; + if (!is_dir($path)) { + $this->warn(" - $folder/ (no existe, skip)"); + continue; + } + + $this->info("Carpeta: $folder/ (max {$cfg['maxWidth']}px, q={$cfg['quality']})"); + $files = glob($path . '/*.{jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP}', GLOB_BRACE); + + $folderOrig = 0; $folderNew = 0; $folderProcessed = 0; $folderSkipped = 0; + + foreach ($files as $file) { + $origSize = filesize($file); + $totalOrig += $origSize; $folderOrig += $origSize; + + $info = @getimagesize($file); + if (!$info) { $folderSkipped++; $totalSkipped++; continue; } + + [$w, $h] = $info; + $needsResize = $w > $cfg['maxWidth']; + $needsRecomp = $origSize > 100 * 1024; // skip si ya pesa <100KB + + if (!$needsResize && !$needsRecomp) { + $folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize; + continue; + } + + $result = $this->processImage($file, $cfg['maxWidth'], $cfg['quality']); + if ($result === null) { + $folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize; + continue; + } + + [$newBytes, $newW, $newH] = $result; + + if ($newBytes >= $origSize) { + $folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize; + $this->line(sprintf(" - %-40s %dKB no mejora, skip", basename($file), $origSize / 1024)); + continue; + } + + $folderProcessed++; $totalProcessed++; + $totalNew += $newBytes; $folderNew += $newBytes; + + $reduction = round((1 - $newBytes / $origSize) * 100, 1); + $this->line(sprintf( + " %s %-40s %dKB -> %dKB (-%s%%) %dx%d -> %dx%d", + $apply ? 'OK' : '--', + basename($file), $origSize / 1024, $newBytes / 1024, $reduction, + $w, $h, $newW, $newH + )); + + if ($apply) { + if (!$noBackup) { + $backupDir = $backupBase . '/' . $folder; + if (!is_dir($backupDir)) mkdir($backupDir, 0755, true); + copy($file, $backupDir . '/' . basename($file)); + } + file_put_contents($file, $result[3]); + } + } + + $this->line(sprintf( + " -> %s files procesados, %s skip, %s -> %s (-%s%%)", + $folderProcessed, $folderSkipped, + $this->fmt($folderOrig), $this->fmt($folderNew), + $folderOrig > 0 ? round((1 - $folderNew / max($folderOrig, 1)) * 100, 1) : 0 + )); + $this->line(''); + } + + $this->line(str_repeat('=', 60)); + $this->info(sprintf( + 'TOTAL: %d procesados, %d skip. %s -> %s (ahorro: %s, -%s%%)', + $totalProcessed, $totalSkipped, + $this->fmt($totalOrig), $this->fmt($totalNew), + $this->fmt($totalOrig - $totalNew), + $totalOrig > 0 ? round((1 - $totalNew / max($totalOrig, 1)) * 100, 1) : 0 + )); + + if (!$apply && $totalProcessed > 0) { + $this->line(''); + $this->warn('Para aplicar realmente: php artisan optimize:images --apply'); + } + + return self::SUCCESS; + } + + private function processImage(string $file, int $maxWidth, int $quality): ?array + { + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + + $img = match ($ext) { + 'jpg', 'jpeg' => @imagecreatefromjpeg($file), + 'png' => @imagecreatefrompng($file), + 'webp' => @imagecreatefromwebp($file), + default => null, + }; + if (!$img) return null; + + $w = imagesx($img); $h = imagesy($img); + $newW = $w; $newH = $h; + + if ($w > $maxWidth) { + $newW = $maxWidth; + $newH = (int) round($h * ($maxWidth / $w)); + $resized = imagecreatetruecolor($newW, $newH); + + if (in_array($ext, ['png', 'webp'])) { + imagealphablending($resized, false); + imagesavealpha($resized, true); + $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127); + imagefilledrectangle($resized, 0, 0, $newW, $newH, $transparent); + } + + imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + $img = $resized; + } + + ob_start(); + match ($ext) { + 'jpg', 'jpeg' => imagejpeg($img, null, $quality), + 'png' => imagepng($img, null, 9), + 'webp' => imagewebp($img, null, $quality), + }; + $bytes = ob_get_clean(); + + return [strlen($bytes), $newW, $newH, $bytes]; + } + + private function fmt(int $bytes): string + { + if ($bytes < 1024) return $bytes . 'B'; + if ($bytes < 1024 * 1024) return round($bytes / 1024, 1) . 'KB'; + return round($bytes / 1024 / 1024, 2) . 'MB'; + } +} diff --git a/app/Console/Commands/PurgeAgentThreads.php b/app/Console/Commands/PurgeAgentThreads.php new file mode 100644 index 0000000..c95e329 --- /dev/null +++ b/app/Console/Commands/PurgeAgentThreads.php @@ -0,0 +1,21 @@ +delete(); + + $this->info("Se eliminaron {$deleted} hilo(s) expirado(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecordatorioPartidos.php b/app/Console/Commands/RecordatorioPartidos.php new file mode 100644 index 0000000..2f56a12 --- /dev/null +++ b/app/Console/Commands/RecordatorioPartidos.php @@ -0,0 +1,86 @@ +addHours(24); + $hasta = Carbon::now()->addHours(50); // ventana de ~26hs para no perder ninguno + + $eventos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->whereNotNull('id_equipo_local') + ->whereNotNull('id_equipo_visitante') + ->whereBetween('fecha_evento', [$desde->toDateString(), $hasta->toDateString()]) + ->get(); + + if ($eventos->isEmpty()) { + $this->info('No hay partidos en las próximas 48hs.'); + return self::SUCCESS; + } + + $totalNotif = 0; + + foreach ($eventos as $evento) { + $nombreLocal = $evento->equipoLocal->club->nombre ?? '?'; + $nombreVisitante = $evento->equipoVisitante->club->nombre ?? '?'; + $fechaStr = $evento->fecha_evento->format('d/m/Y'); + $horaStr = $evento->hora_inicio ? $evento->hora_inicio->format('H:i') : ''; + $sedeStr = $evento->sede ? " | {$evento->sede}" : ''; + + $titulo = "⏰ Partido mañana: {$nombreLocal} vs {$nombreVisitante}"; + $mensaje = "Recordatorio: el partido es el {$fechaStr}" . ($horaStr ? " a las {$horaStr}" : '') . $sedeStr . '.'; + $url = '/eventos/' . $evento->id_evento; + + // Recolectar destinatarios + $idEquipos = array_filter([$evento->id_equipo_local, $evento->id_equipo_visitante]); + $destinatarios = []; + $yaAgregados = []; + + $seguimientos = EquipoSeguimiento::whereIn('id_equipo', $idEquipos)->get(); + foreach ($seguimientos as $s) { + $key = $s->tipo_usuario . ':' . $s->id_usuario; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => $s->tipo_usuario, 'id' => $s->id_usuario]; + $yaAgregados[$key] = true; + } + } + + $jugadores = \DB::table('jugador_equipo') + ->whereIn('id_equipo', $idEquipos) + ->pluck('id_jugador'); + + foreach ($jugadores as $idJ) { + $key = 'jugador:' . $idJ; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => 'jugador', 'id' => $idJ]; + $yaAgregados[$key] = true; + } + } + + if (empty($destinatarios)) continue; + + if ($this->option('test')) { + $this->line(" [TEST] Evento {$evento->id_evento}: {$titulo} → " . count($destinatarios) . " destinatarios"); + } else { + $notifService->enviarMasivo($destinatarios, 'partido', $titulo, $mensaje, $url); + } + + $totalNotif += count($destinatarios); + } + + $this->info("✅ Recordatorios enviados: {$eventos->count()} partidos, {$totalNotif} notificaciones."); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ReporteSemanal.php b/app/Console/Commands/ReporteSemanal.php new file mode 100644 index 0000000..9eb1abb --- /dev/null +++ b/app/Console/Commands/ReporteSemanal.php @@ -0,0 +1,106 @@ +copy()->subDays(7)->startOfDay(); + $semanaAnteriorHasta = $ahora->copy()->subDay()->endOfDay(); + $proximaSemanaHasta = $ahora->copy()->addDays(7)->endOfDay(); + + // ── Partidos jugados la semana anterior ── + $jugados = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->whereBetween('fecha_evento', [$semanaAnteriorDesde->toDateString(), $semanaAnteriorHasta->toDateString()]) + ->whereNotNull('marcador_local') + ->get(); + + // ── Próximos partidos ── + $proximos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->where('fecha_evento', '>=', $ahora->toDateString()) + ->where('fecha_evento', '<=', $proximaSemanaHasta->toDateString()) + ->orderBy('fecha_evento') + ->orderBy('hora_inicio') + ->get(); + + // ── Top goleadores ── + $topGoleadores = DB::table('evento_jugador') + ->join('jugadores', 'evento_jugador.id_jugador', '=', 'jugadores.id_jugador') + ->selectRaw('jugadores.nombre, jugadores.apellido, SUM(evento_jugador.puntos) as total_puntos, COUNT(evento_jugador.id_evento) as partidos') + ->groupBy('jugadores.id_jugador', 'jugadores.nombre', 'jugadores.apellido') + ->orderByDesc('total_puntos') + ->limit(10) + ->get(); + + // ── QRs de la semana ── + $qrsSemana = DB::table('qr_codes') + ->where('creado', '>=', $semanaAnteriorDesde) + ->count(); + + $qrsValidados = DB::table('qr_codes') + ->where('creado', '>=', $semanaAnteriorDesde) + ->where('escaneos_restantes', 0) + ->count(); + + $data = compact('jugados', 'proximos', 'topGoleadores', 'qrsSemana', 'qrsValidados', 'semanaAnteriorDesde', 'semanaAnteriorHasta'); + + if ($this->option('dry-run')) { + $this->info('=== REPORTE SEMANAL (DRY RUN) ==='); + $this->line("Partidos jugados: {$jugados->count()}"); + $this->line("Próximos partidos: {$proximos->count()}"); + $this->line("Top goleador: " . ($topGoleadores->first() ? $topGoleadores->first()->apellido . ' (' . $topGoleadores->first()->total_puntos . ' pts)' : 'N/A')); + $this->line("QRs generados: {$qrsSemana} | Validados: {$qrsValidados}"); + return self::SUCCESS; + } + + // 1. Priorizar email configurado en sistema + $emails = []; + $configEmail = \App\Models\Configuracion::get('email_reportes'); + if ($configEmail) { + $emails[] = $configEmail; + } + + // 2. Si no hay, buscar superadmins (rol=1) + if (empty($emails)) { + $emails = DB::table('admin_users')->where('rol', 1)->whereNotNull('email')->pluck('email')->toArray(); + } + + // 3. Fallback: email del .env + if (empty($emails)) { + $fallback = config('mail.from.address'); + if ($fallback) $emails = [$fallback]; + } + + if (empty($emails)) { + $this->error('No hay destinatarios configurados para enviar el reporte.'); + return self::FAILURE; + } + + try { + Mail::send('emails.reporte_semanal', $data, function ($mail) use ($emails, $ahora) { + $mail->to($emails) + ->subject("📊 Reporte Semanal ONAPB — Semana del " . $ahora->startOfWeek()->format('d/m')); + }); + $this->info("✅ Reporte enviado a: " . implode(', ', $emails)); + } catch (\Exception $e) { + Log::error('Error enviando reporte semanal: ' . $e->getMessage()); + $this->error('Error: ' . $e->getMessage()); + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php new file mode 100644 index 0000000..3042a13 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -0,0 +1,115 @@ +checkSuperAdmin($request); + $usuarios = AdminUser::with('club')->orderBy('id', 'desc')->paginate(20); + return view('admin.usuarios.index', compact('usuarios')); + } + + public function create(Request $request) + { + $this->checkSuperAdmin($request); + $usuario = null; + $clubes = Club::orderBy('nombre')->get(); + return view('admin.usuarios.form', compact('usuario', 'clubes')); + } + + public function store(Request $request) + { + $this->checkSuperAdmin($request); + + $data = $request->validate([ + 'username' => 'required|string|max:50|unique:admin_users', + 'password' => 'required|string|min:6', + 'role' => 'required|integer|in:1,2', + 'id_club' => 'nullable|integer|exists:clubes,id_club' + ]); + + if ($data['role'] == 2 && empty($data['id_club'])) { + return back()->withErrors(['id_club' => 'Si el rol es Admin de Club, se requiere un club asociado.'])->withInput(); + } + + if ($data['role'] == 1) { + $data['id_club'] = null; // Superadmins no pertenecen a un club específico en este contexto + } + + $data['password'] = Hash::make($data['password']); + + AdminUser::create($data); + + return redirect()->route('admin.usuarios.index')->with('admin_msg', 'Administrador creado exitosamente.'); + } + + public function edit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $usuario = AdminUser::findOrFail($id); + $clubes = Club::orderBy('nombre')->get(); + return view('admin.usuarios.form', compact('usuario', 'clubes')); + } + + public function update(Request $request, $id) + { + $this->checkSuperAdmin($request); + $usuario = AdminUser::findOrFail($id); + + $data = $request->validate([ + 'username' => ['required', 'string', 'max:50', Rule::unique('admin_users')->ignore($usuario->id)], + 'password' => 'nullable|string|min:6', + 'role' => 'required|integer|in:1,2', + 'id_club' => 'nullable|integer|exists:clubes,id_club' + ]); + + if ($data['role'] == 2 && empty($data['id_club'])) { + return back()->withErrors(['id_club' => 'Si el rol es Admin de Club, se requiere un club asociado.'])->withInput(); + } + + if ($data['role'] == 1) { + $data['id_club'] = null; + } + + if (!empty($data['password'])) { + $data['password'] = Hash::make($data['password']); + } else { + unset($data['password']); + } + + $usuario->update($data); + + return redirect()->route('admin.usuarios.index')->with('admin_msg', 'Administrador actualizado exitosamente.'); + } + + public function destroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + + $usuario = AdminUser::findOrFail($id); + + if ($usuario->id == session('admin_id')) { + return back()->with('admin_error', 'No puedes eliminar tu propio usuario.'); + } + + $usuario->delete(); + + return redirect()->route('admin.usuarios.index')->with('admin_msg', 'Administrador eliminado.'); + } +} diff --git a/app/Http/Controllers/Admin/CarouselItemController.php b/app/Http/Controllers/Admin/CarouselItemController.php new file mode 100644 index 0000000..afecb19 --- /dev/null +++ b/app/Http/Controllers/Admin/CarouselItemController.php @@ -0,0 +1,113 @@ +checkGeneralAdmin($request); + $items = CarouselItem::orderBy('orden', 'asc')->latest()->get(); + return view('admin.carousel.index', compact('items')); + } + + public function create(Request $request) + { + $this->checkGeneralAdmin($request); + return view('admin.carousel.create'); + } + + public function store(Request $request) + { + $this->checkGeneralAdmin($request); + $request->validate([ + 'titulo' => 'nullable|string|max:255', + 'subtitulo' => 'nullable|string|max:255', + 'boton_texto' => 'nullable|string|max:255', + 'boton_enlace' => 'nullable|string|max:255', + 'imagen' => 'required|image|mimes:jpeg,png,jpg,webp|max:5120', + 'orden' => 'nullable|integer', + 'activo' => 'nullable|boolean', + ]); + + $data = $request->except('imagen'); + $data['activo'] = $request->has('activo'); + + if ($request->hasFile('imagen')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'carousel'); + $data['imagen'] = 'storage/' . $path; + } + + CarouselItem::create($data); + + return redirect()->route('admin.carousel.index')->with('admin_msg', 'Slide creado exitosamente.'); + } + + public function edit(Request $request, CarouselItem $carouselItem) + { + $this->checkGeneralAdmin($request); + return view('admin.carousel.edit', compact('carouselItem')); + } + + public function update(Request $request, CarouselItem $carouselItem) + { + $this->checkGeneralAdmin($request); + $request->validate([ + 'titulo' => 'nullable|string|max:255', + 'subtitulo' => 'nullable|string|max:255', + 'boton_texto' => 'nullable|string|max:255', + 'boton_enlace' => 'nullable|string|max:255', + 'imagen' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:5120', + 'orden' => 'nullable|integer', + 'activo' => 'nullable|boolean', + ]); + + $data = $request->except('imagen'); + $data['activo'] = $request->has('activo'); + + if ($request->hasFile('imagen')) { + // Eliminar imagen anterior si existe + if ($carouselItem->imagen) { + $oldPath = public_path($carouselItem->imagen); + if (File::exists($oldPath)) { + File::delete($oldPath); + } + } + + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'carousel'); + $data['imagen'] = 'storage/' . $path; + } + + $carouselItem->update($data); + + return redirect()->route('admin.carousel.index')->with('admin_msg', 'Slide actualizado exitosamente.'); + } + + public function destroy(Request $request, CarouselItem $carouselItem) + { + $this->checkGeneralAdmin($request); + if ($carouselItem->imagen) { + $imagePath = public_path($carouselItem->imagen); + if (File::exists($imagePath)) { + File::delete($imagePath); + } + } + + $carouselItem->delete(); + + return redirect()->route('admin.carousel.index')->with('admin_msg', 'Slide eliminado exitosamente.'); + } +} diff --git a/app/Http/Controllers/Admin/CategoriaController.php b/app/Http/Controllers/Admin/CategoriaController.php new file mode 100644 index 0000000..66a8ef3 --- /dev/null +++ b/app/Http/Controllers/Admin/CategoriaController.php @@ -0,0 +1,74 @@ +checkSuperAdmin($request); + $categorias = Categoria::latest()->get(); + return view('admin.categorias.index', compact('categorias')); + } + + public function create(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.categorias.form', ['categoria' => null]); + } + + public function store(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:50', + 'edad_min' => 'required|integer', + 'edad_max' => 'required|integer|gte:edad_min', + 'genero' => 'nullable|string|max:20', + ]); + $data['es_libre'] = $request->has('es_libre'); + Categoria::create($data); + return redirect()->route('admin.categorias.index')->with('admin_msg', 'Categoría creada.'); + } + + public function edit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $categoria = Categoria::findOrFail($id); + return view('admin.categorias.form', compact('categoria')); + } + + public function update(Request $request, $id) + { + $this->checkSuperAdmin($request); + $categoria = Categoria::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'required|string|max:50', + 'edad_min' => 'required|integer', + 'edad_max' => 'required|integer|gte:edad_min', + 'genero' => 'nullable|string|max:20', + ]); + $data['es_libre'] = $request->has('es_libre'); + $categoria->update($data); + return redirect()->route('admin.categorias.index')->with('admin_msg', 'Categoría actualizada.'); + } + + public function destroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $categoria = Categoria::findOrFail($id); + $categoria->delete(); + return redirect()->route('admin.categorias.index')->with('admin_msg', 'Categoría eliminada.'); + } +} diff --git a/app/Http/Controllers/Admin/FixtureController.php b/app/Http/Controllers/Admin/FixtureController.php new file mode 100644 index 0000000..7101fa7 --- /dev/null +++ b/app/Http/Controllers/Admin/FixtureController.php @@ -0,0 +1,380 @@ +fixtureService = $fixtureService; + } + + private function checkSuperAdmin() + { + if (!session('admin_logged_in') || session('admin_role') != 1) { + abort(403, 'Solo Súper Administradores pueden generar fixtures.'); + } + } + + /** + * POST /admin/torneos/{id}/generar-fixture — Preview del fixture + */ + public function preview(Request $request, int $id) + { + $this->checkSuperAdmin(); + + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + $request->validate([ + 'fecha_inicio' => 'required|date|after_or_equal:today', + 'dias_entre_jornadas' => 'required|integer|min:1|max:60', + 'sede_default' => 'nullable|string|max:200', + 'doble_rueda' => 'nullable|boolean', + ]); + + try { + $partidos = $this->fixtureService->generarRoundRobin( + torneo: $torneo, + fechaInicio: $request->input('fecha_inicio'), + diasEntreJornadas: (int) $request->input('dias_entre_jornadas', 7), + sedeDefault: $request->input('sede_default', ''), + dobleRueda: (bool) $request->input('doble_rueda', false), + ); + + // Enriquecer con nombres para la vista + $partidosEnriquecidos = collect($partidos)->map(function ($p) { + $local = \App\Models\Equipo::with('club')->find($p['id_equipo_local']); + $visitante = \App\Models\Equipo::with('club')->find($p['id_equipo_visitante']); + return array_merge($p, [ + 'nombre_local' => ($local->club->nombre ?? '?') . ' (' . ($local->categoria ?? '') . ')', + 'nombre_visitante' => ($visitante->club->nombre ?? '?') . ' (' . ($visitante->categoria ?? '') . ')', + ]); + })->toArray(); + + } catch (\InvalidArgumentException $e) { + return back()->with('admin_error', $e->getMessage()); + } + + $fixtureParams = [ + 'fecha_inicio' => $request->input('fecha_inicio'), + 'dias_entre_jornadas' => $request->input('dias_entre_jornadas', 7), + 'sede_default' => $request->input('sede_default', ''), + 'doble_rueda' => (bool) $request->input('doble_rueda', false), + ]; + + return view('admin.torneos.fixture_preview', compact('torneo', 'partidosEnriquecidos', 'fixtureParams')); + + } + + /** + * POST /admin/torneos/{id}/confirmar-fixture — Persiste el fixture + */ + public function confirmar(Request $request, int $id) + { + $this->checkSuperAdmin(); + + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + $request->validate([ + 'fecha_inicio' => 'required|date', + 'dias_entre_jornadas' => 'required|integer|min:1', + 'sede_default' => 'nullable|string|max:200', + 'doble_rueda' => 'nullable|boolean', + ]); + + try { + $partidos = $this->fixtureService->generarRoundRobin( + torneo: $torneo, + fechaInicio: $request->input('fecha_inicio'), + diasEntreJornadas: (int) $request->input('dias_entre_jornadas', 7), + sedeDefault: $request->input('sede_default', ''), + dobleRueda: (bool) $request->input('doble_rueda', false), + ); + + $creados = $this->fixtureService->persistirFixture($partidos, $torneo); + + } catch (\InvalidArgumentException $e) { + return back()->with('admin_error', $e->getMessage()); + } + + return redirect()->route('admin.torneos.edit', $id) + ->with('admin_msg', "✅ Fixture generado correctamente: {$creados} partidos creados."); + } + + public function importForm(int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::findOrFail($id); + return view('admin.torneos.importar', compact('torneo')); + } + + public function importStore(Request $request, int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::with(['equipos.club'])->findOrFail($id); + + $request->validate([ + 'texto_importar' => 'required|string', + ]); + + $lineas = explode("\n", $request->input('texto_importar')); + $creados = 0; + $errores = []; + + foreach ($lineas as $index => $linea) { + $linea = trim($linea); + if (empty($linea)) continue; + + try { + // Formato esperado: Fecha, ClubL, CatL, ClubV, CatV, MarcadorL, MarcadorV, Sede, Grupo + $partes = str_getcsv($linea); + + if (count($partes) < 7) { + throw new \Exception("Formato insuficiente. Se esperan al menos 7 columnas."); + } + + $fechaRaw = trim($partes[0]); + $fecha = \Carbon\Carbon::parse(str_replace('/', '-', $fechaRaw))->format('Y-m-d'); + + $nombreClubL = trim($partes[1]); + $catL = trim($partes[2]); + $nombreClubV = trim($partes[3]); + $catV = trim($partes[4]); + $marcadorL = trim($partes[5]); + $marcadorV = trim($partes[6]); + $sede = $partes[7] ?? null; + $grupo = $partes[8] ?? null; + + // Buscar equipos con validación de grupo (prioridad) y categoría + $equipoL = $this->buscarEquipo($nombreClubL, $catL, $torneo, $grupo); + $equipoV = $this->buscarEquipo($nombreClubV, $catV, $torneo, $grupo); + + if (!$equipoL || !$equipoV) { + $missing = !$equipoL ? "'{$nombreClubL} ({$catL})'" : "'{$nombreClubV} ({$catV})'"; + throw new \Exception("No se encontró el equipo {$missing} en el grupo '" . ($grupo ?? 'N/A') . "' ni por categoría."); + } + + \App\Models\Evento::create([ + 'id_evento' => uniqid('ev_imp_'), + 'id_torneo' => $id, + 'fase' => \App\Models\Evento::FASE_REGULAR, + 'fecha_evento' => $fecha, + 'id_equipo_local' => $equipoL->id_equipo, + 'id_equipo_visitante' => $equipoV->id_equipo, + 'marcador_local' => $marcadorL !== '' ? (int)$marcadorL : null, + 'marcador_visitante' => $marcadorV !== '' ? (int)$marcadorV : null, + 'nombre_evento' => $equipoL->club->nombre . ' vs ' . $equipoV->club->nombre, + 'hora_inicio' => '19:00:00', + 'hora_fin' => '21:00:00', + 'sede' => $sede, + 'precio' => 0, + ]); + + $creados++; + } catch (\Exception $e) { + $errores[] = "Línea " . ($index + 1) . ": " . $e->getMessage(); + continue; + } + } + + $msg = "Se importaron {$creados} partidos exitosamente."; + if (count($errores) > 0) { + return back()->with('admin_msg', $msg)->with('admin_error', "Se detectaron errores en " . count($errores) . " líneas:
" . implode("
", $errores)); + } + + return redirect()->route('admin.torneos.edit', $id)->with('admin_msg', $msg); + } + + private function buscarEquipo(string $clubName, string $category, Torneo $torneo, ?string $grupo = null) + { + $clubNameNorm = $this->normalizeString($clubName); + $categoryNorm = $this->normalizeString($category); + $grupoNorm = $grupo ? $this->normalizeString($grupo) : null; + + // PASO 1: Prioridad absoluta al GRUPO (vía pivot) + if ($grupoNorm) { + $match = $torneo->equipos->first(function($e) use ($clubNameNorm, $grupoNorm) { + $eClubName = $this->normalizeString($e->club->nombre ?? ''); + $eGrupo = $this->normalizeString($e->pivot->grupo ?? ''); + + return ($eGrupo === $grupoNorm) && + (strpos($eClubName, $clubNameNorm) !== false || strpos($clubNameNorm, $eClubName) !== false); + }); + if ($match) return $match; + } + + // PASO 2: Fallback a búsqueda por CATEGORÍA si el grupo no coincide o no se especificó + return $torneo->equipos->first(function($e) use ($clubNameNorm, $categoryNorm) { + $eClubName = $this->normalizeString($e->club->nombre ?? ''); + $eCategory = $this->normalizeString($e->categoria ?? ''); + + $matchClub = (strpos($eClubName, $clubNameNorm) !== false || strpos($clubNameNorm, $eClubName) !== false); + $matchCat = (strpos($eCategory, $categoryNorm) !== false || strpos($categoryNorm, $eCategory) !== false); + + return $matchClub && $matchCat; + }); + } + + private function normalizeString(?string $str): string + { + if (!$str) return ''; + $str = mb_strtolower(trim($str), 'UTF-8'); + $str = str_replace( + ['á', 'é', 'í', 'ó', 'ú', 'ü', 'ñ'], + ['a', 'e', 'i', 'o', 'u', 'u', 'n'], + $str + ); + // Eliminar caracteres no alfanuméricos para un matching más flexible + return preg_replace('/[^a-z0-9]/', '', $str); + } + + public function generarPlayoffs(Request $request, int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::findOrFail($id); + $grupo = $request->input('grupo'); + $formato = (int) $request->input('formato', 1); // 1, 3, 5 + + if (!$grupo) { + return back()->with('admin_error', 'Debes seleccionar un grupo para generar los playoffs.'); + } + + $ts = new \App\Services\TournamentService(); + $standings = $ts->getStandings($id, true); + + if (!isset($standings[$grupo])) { + return back()->with('admin_error', "No hay equipos en el grupo {$grupo}."); + } + + $top8 = array_slice($standings[$grupo], 0, 8); + + if (count($top8) < 2) { + return back()->with('admin_error', "Se necesitan al menos 2 equipos para generar playoffs."); + } + + // Determinar emparejamientos (1 vs 8, 4 vs 5, 2 vs 7, 3 vs 6) + // Usamos el seeding oficial: 1v8, 4v5, 2v7, 3v6 + $parejas = [ + ['local' => $top8[0]['id'], 'visit' => $top8[7]['id'] ?? null, 'nro' => 1], + ['local' => $top8[3]['id'] ?? null, 'visit' => $top8[4]['id'] ?? null, 'nro' => 2], + ['local' => $top8[1]['id'] ?? null, 'visit' => $top8[6]['id'] ?? null, 'nro' => 3], + ['local' => $top8[2]['id'] ?? null, 'visit' => $top8[5]['id'] ?? null, 'nro' => 4], + ]; + + $count = 0; + foreach ($parejas as $p) { + if (!$p['local'] || !$p['visit']) continue; + + $local = \App\Models\Equipo::with('club')->find($p['local']); + $visit = \App\Models\Equipo::with('club')->find($p['visit']); + + // Crear N partidos según el formato + for ($i = 0; $i < $formato; $i++) { + \App\Models\Evento::create([ + 'id_evento' => uniqid('ply_'), + 'id_torneo' => $id, + 'fase' => \App\Models\Evento::FASE_CUARTOS, + 'numero_partido_bracket' => $p['nro'], + 'id_equipo_local' => $p['local'], + 'id_equipo_visitante' => $p['visit'], + 'nombre_evento' => "4tos (J".($i+1)."): " . $local->club->nombre . " vs " . $visit->club->nombre . " ({$grupo})", + 'fecha_evento' => now()->addDays(7 + ($i * 3))->format('Y-m-d'), + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'precio' => 0, + ]); + $count++; + } + } + + return redirect()->route('admin.torneos.playoffs.manage', $id) + ->with('admin_msg', "Playoffs generados: {$count} partidos creados para el grupo {$grupo}."); + } + + public function managePlayoffs(int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::findOrFail($id); + + $ts = new \App\Services\TournamentService(); + $bracket = $ts->getPlayoffBrackets($id); + + $grupos = \DB::table('torneo_equipo')->where('id_torneo', $id)->distinct()->pluck('grupo')->filter(); + + return view('admin.torneos.playoff_manage', compact('torneo', 'bracket', 'grupos')); + } + + public function avanzarGanador(Request $request, int $id) + { + $this->checkSuperAdmin(); + $faseActual = (int) $request->input('fase'); + $nroBracket = (int) $request->input('nro_bracket'); + $idGanador = (int) $request->input('id_ganador'); + + $torneo = Torneo::findOrFail($id); + $ganador = \App\Models\Equipo::with('club')->findOrFail($idGanador); + + // Mapeo de avance + // Cuartos 1 & 2 -> Semis 1 + // Cuartos 3 & 4 -> Semis 2 + // Semis 1 & 2 -> Final 1 + $mapping = [ + \App\Models\Evento::FASE_CUARTOS => [ + 1 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 1, 'side' => 'local'], + 2 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 1, 'side' => 'visitante'], + 3 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 2, 'side' => 'local'], + 4 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 2, 'side' => 'visitante'], + ], + \App\Models\Evento::FASE_SEMIS => [ + 1 => ['next_fase' => \App\Models\Evento::FASE_FINAL, 'next_nro' => 1, 'side' => 'local'], + 2 => ['next_fase' => \App\Models\Evento::FASE_FINAL, 'next_nro' => 1, 'side' => 'visitante'], + ] + ]; + + if (!isset($mapping[$faseActual][$nroBracket])) { + return back()->with('admin_error', 'No se puede avanzar desde esta fase.'); + } + + $next = $mapping[$faseActual][$nroBracket]; + + // Buscar si ya existe el partido en la siguiente fase + $eventoNext = \App\Models\Evento::where('id_torneo', $id) + ->where('fase', $next['next_fase']) + ->where('numero_partido_bracket', $next['next_nro']) + ->first(); + + if ($eventoNext) { + if ($next['side'] == 'local') { + $eventoNext->id_equipo_local = $idGanador; + } else { + $eventoNext->id_equipo_visitante = $idGanador; + } + $eventoNext->nombre_evento = ($eventoNext->equipoLocal->club->nombre ?? 'TBD') . ' vs ' . ($eventoNext->equipoVisitante->club->nombre ?? 'TBD'); + $eventoNext->save(); + } else { + // Crear el primer partido de la serie (por defecto 1 partido, el admin puede agregar más) + \App\Models\Evento::create([ + 'id_evento' => uniqid('ply_adv_'), + 'id_torneo' => $id, + 'fase' => $next['next_fase'], + 'numero_partido_bracket' => $next['next_nro'], + 'id_equipo_local' => $next['side'] == 'local' ? $idGanador : null, + 'id_equipo_visitante' => $next['side'] == 'visitante' ? $idGanador : null, + 'nombre_evento' => $next['side'] == 'local' ? ($ganador->club->nombre . " vs TBD") : ("TBD vs " . $ganador->club->nombre), + 'fecha_evento' => now()->addDays(14)->format('Y-m-d'), + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'precio' => 0, + ]); + } + + return back()->with('admin_msg', "✅ Equipo {$ganador->club->nombre} avanzado a la siguiente ronda."); + } +} diff --git a/app/Http/Controllers/Admin/PaseController.php b/app/Http/Controllers/Admin/PaseController.php new file mode 100644 index 0000000..e05d4c5 --- /dev/null +++ b/app/Http/Controllers/Admin/PaseController.php @@ -0,0 +1,129 @@ +checkGeneralAdmin($request); + + $query = Pase::with(['jugador', 'clubOrigen', 'clubDestino']); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $query->where(function ($q) use ($idClub) { + $q->where('id_club_origen', $idClub) + ->orWhere('id_club_destino', $idClub); + }); + } + + $pases = $query->orderBy('created_at', 'desc')->paginate(20); + return view('admin.pases.index', compact('pases')); + } + + public function create(Request $request) + { + $this->checkGeneralAdmin($request); + return view('admin.pases.create'); + } + + public function store(Request $request) + { + $this->checkGeneralAdmin($request); + + $request->validate([ + 'documento' => 'required|string|exists:jugadores,documento' + ], [ + 'documento.exists' => 'No se encontró un jugador con ese DNI.' + ]); + + $jugador = Jugador::where('documento', $request->documento)->first(); + + // Si el jugador ya pertenece al club destino, error + $idClubDestino = session('admin_role') == 2 ? session('admin_id_club') : $request->input('id_club_destino'); + + if (!$idClubDestino) { + return back()->withErrors(['id_club_destino' => 'Debe especificar el club destino.']); + } + + if ($jugador->id_club_actual == $idClubDestino) { + return back()->withErrors(['documento' => 'Este jugador ya pertenece a tu club.']); + } + + // Crear la petición de pase + Pase::create([ + 'id_jugador' => $jugador->id_jugador, + 'id_club_origen' => $jugador->id_club_actual, + 'id_club_destino' => $idClubDestino, + 'estado' => 'Pendiente' + ]); + + return redirect()->route('admin.pases.index')->with('admin_msg', 'Solicitud de pase creada correctamente y está pendiente de aprobación.'); + } + + public function aprobar(Request $request, $id) + { + $this->checkSuperAdmin($request); + $pase = Pase::findOrFail($id); + + if ($pase->estado !== 'Pendiente') { + return back()->withErrors(['pase' => 'Este pase ya fue procesado.']); + } + + $pase->update(['estado' => 'Aprobado']); + + // Actualizar jugador + if ($pase->jugador) { + // Desvincular de equipos del club de origen + $equiposOldClubIds = $pase->jugador->equipos() + ->where('id_club', $pase->id_club_origen) + ->pluck('equipos.id_equipo'); + + if ($equiposOldClubIds->count() > 0) { + $pase->jugador->equipos()->detach($equiposOldClubIds); + } + + $pase->jugador->update([ + 'id_club_origen' => $pase->id_club_origen, + 'id_club_actual' => $pase->id_club_destino + ]); + } + + return redirect()->route('admin.pases.index')->with('admin_msg', 'Pase aprobado correctamente y jugador desvinculado de equipos anteriores.'); + } + + public function rechazar(Request $request, $id) + { + $this->checkSuperAdmin($request); + $pase = Pase::findOrFail($id); + + if ($pase->estado !== 'Pendiente') { + return back()->withErrors(['pase' => 'Este pase ya fue procesado.']); + } + + $pase->update(['estado' => 'Rechazado']); + + return redirect()->route('admin.pases.index')->with('admin_msg', 'Pase rechazado.'); + } +} diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php new file mode 100644 index 0000000..10de9e6 --- /dev/null +++ b/app/Http/Controllers/AdminController.php @@ -0,0 +1,1667 @@ +checkGeneralAdmin($request); + + if (session('admin_role') == 1) { + $stats = [ + 'clubes' => Club::count(), + 'equipos' => Equipo::count(), + 'jugadores' => Jugador::count(), + 'eventos' => Evento::count(), + 'promociones' => Promocion::count(), + 'noticias' => Noticia::count(), + ]; + $miClub = null; + } else { + $idClub = session('admin_id_club'); + $miClub = Club::find($idClub); + + $stats = [ + 'equipos' => Equipo::where('id_club', $idClub)->count(), + 'jugadores' => Jugador::where('id_club_actual', $idClub)->count(), + 'eventos' => Evento::whereHas('equipoLocal', function($q) use ($idClub) { + $q->where('id_club', $idClub); + })->orWhereHas('equipoVisitante', function($q) use ($idClub) { + $q->where('id_club', $idClub); + })->count(), + 'promociones' => Promocion::count(), // Promociones son generales + ]; + } + + return view('admin.dashboard', compact('stats', 'miClub')); + } + + // ══════════════════════════════════ + // CLUBES + // ══════════════════════════════════ + public function clubesIndex(Request $request) + { + $this->checkSuperAdmin($request); + $search = trim((string) $request->input('q', '')); + + // Recién creados/editados primero; registros viejos sin timestamps caen al fallback por id. + $query = Club::withCount('equipos', 'jugadores') + ->orderByRaw("COALESCE(updated_at, '1970-01-01') DESC") + ->orderBy('id_club', 'desc'); + + if ($search !== '') { + $query->where('nombre', 'like', '%' . $search . '%'); + } + + $clubes = $query->get(); + return view('admin.clubes.index', compact('clubes', 'search')); + } + + public function clubesCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.clubes.form', ['club' => null]); + } + + public function clubesStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'id_club' => 'nullable|integer', + 'nombre' => 'required|string|max:100', + 'es_seleccion' => 'nullable|boolean', + ]); + $data['es_seleccion'] = $request->boolean('es_seleccion'); + Club::create($data); + return redirect()->route('admin.clubes.index')->with('admin_msg', 'Club creado correctamente.'); + } + + public function clubesEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + if (session('admin_role') == 2 && session('admin_id_club') != $id) { + abort(403, 'No tienes permiso para editar este club.'); + } + $club = Club::findOrFail($id); + return view('admin.clubes.form', compact('club')); + } + + public function clubesUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + if (session('admin_role') == 2 && session('admin_id_club') != $id) { + abort(403, 'No tienes permiso para editar este club.'); + } + + $club = Club::findOrFail($id); + + $rules = [ + 'qr_color_texto' => 'nullable|string|max:20', + 'qr_background' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048', + 'logo_club' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:1024' + ]; + + if (session('admin_role') == 1) { + $rules['nombre'] = 'required|string|max:100'; + $rules['es_seleccion'] = 'nullable|boolean'; + } + + $data = $request->validate($rules); + + if (session('admin_role') == 1) { + $data['es_seleccion'] = $request->boolean('es_seleccion'); + } + + // Manejo de Logo del Club + if ($request->hasFile('logo_club')) { + // Eliminar logo anterior si existe + if ($club->imagen && file_exists(public_path($club->imagen))) { + @unlink(public_path($club->imagen)); + } + $logoPath = app(ImageOptimizer::class)->storeAndOptimize($request->file('logo_club'), 'clubes'); + $data['imagen'] = 'storage/' . $logoPath; + } + + // Manejo de Fondo QR + if ($request->hasFile('qr_background')) { + // Eliminar fondo anterior si existe + if ($club->qr_background && file_exists(public_path($club->qr_background))) { + @unlink(public_path($club->qr_background)); + } + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('qr_background'), 'qr'); + $data['qr_background'] = 'storage/' . $path; + } + + $club->update($data); + + if (session('admin_role') == 2) { + return back()->with('admin_msg', 'Plantilla de QR actualizada correctamente.'); + } + + return redirect()->route('admin.clubes.index')->with('admin_msg', 'Club actualizado correctamente.'); + } + + public function clubesDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $club = Club::findOrFail($id); + $club->delete(); + return redirect()->route('admin.clubes.index')->with('admin_msg', 'Club eliminado correctamente.'); + } + + // ══════════════════════════════════ + // EQUIPOS + // ══════════════════════════════════ + public function equiposIndex(Request $request) + { + $this->checkGeneralAdmin($request); + $search = trim((string) $request->input('q', '')); + + $query = Equipo::with('club')->withCount('jugadores') + ->orderByRaw("COALESCE(updated_at, '1970-01-01') DESC") + ->orderBy('id_equipo', 'desc'); + + if (session('admin_role') == 2) { + $query->where('id_club', session('admin_id_club')); + } + + if ($search !== '') { + $like = '%' . $search . '%'; + $query->where(function ($q) use ($like, $search) { + $q->where('categoria', 'like', $like) + ->orWhere('division', 'like', $like) + ->orWhereHas('club', function ($c) use ($like) { + $c->where('nombre', 'like', $like); + }); + + if (ctype_digit($search)) { + $q->orWhere('id_equipo', (int) $search); + } + }); + } + + $equipos = $query->get(); + return view('admin.equipos.index', compact('equipos', 'search')); + } + + public function equiposCreate(Request $request) + { + $this->checkGeneralAdmin($request); + + if (session('admin_role') == 1) { + $clubes = Club::orderBy('nombre')->get(); + } else { + $clubes = Club::where('id_club', session('admin_id_club'))->get(); + } + + $categorias = \App\Models\Categoria::orderBy('nombre')->get(); + return view('admin.equipos.form', ['equipo' => null, 'clubes' => $clubes, 'categorias' => $categorias]); + } + + public function equiposStore(Request $request) + { + $this->checkGeneralAdmin($request); + + $data = $request->validate([ + 'id_club' => session('admin_role') == 1 ? 'required|integer|exists:clubes,id_club' : 'nullable', + 'categoria' => 'required|string|max:20', + 'division' => 'required|string|max:5', + ]); + + if (session('admin_role') == 2) { + $data['id_club'] = session('admin_id_club'); + } + + Equipo::create($data); + return redirect()->route('admin.equipos.index')->with('admin_msg', 'Equipo creado correctamente.'); + } + + public function equiposEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para editar este equipo.'); + } + + if (session('admin_role') == 1) { + $clubes = Club::orderBy('nombre')->get(); + } else { + $clubes = Club::where('id_club', session('admin_id_club'))->get(); + } + + $categorias = \App\Models\Categoria::orderBy('nombre')->get(); + return view('admin.equipos.form', compact('equipo', 'clubes', 'categorias')); + } + + public function equiposUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para editar este equipo.'); + } + + $data = $request->validate([ + 'id_club' => session('admin_role') == 1 ? 'required|integer|exists:clubes,id_club' : 'nullable', + 'categoria' => 'required|string|max:20', + 'division' => 'required|string|max:5', + ]); + + if (session('admin_role') == 2) { + $data['id_club'] = session('admin_id_club'); + } + + $equipo->update($data); + return redirect()->route('admin.equipos.index')->with('admin_msg', 'Equipo actualizado correctamente.'); + } + + public function equiposDestroy(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para eliminar este equipo.'); + } + + $equipo->delete(); + return redirect()->route('admin.equipos.index')->with('admin_msg', 'Equipo eliminado correctamente.'); + } + + public function equipoJugadores($id) + { + $this->checkGeneralAdmin(request()); + $equipo = Equipo::with('club', 'jugadores')->findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para gestionar jugadores de este equipo.'); + } + + return view('admin.equipos.jugadores', compact('equipo')); + } + + public function equipoAddJugador(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403); + } + + $data = $request->validate([ + 'id_jugador' => 'required|string|exists:jugadores,id_jugador', + ]); + + // Verificar que el jugador sea del mismo club, salvo que el equipo pertenezca a una selección + $jugador = Jugador::where('id_jugador', $data['id_jugador'])->firstOrFail(); + $esSeleccion = $equipo->club && $equipo->club->es_seleccion; + if (!$esSeleccion && $jugador->id_club_actual != $equipo->id_club) { + return back()->with('admin_error', 'El jugador no pertenece al mismo club que el equipo.'); + } + + // Evitar duplicados + if ($equipo->jugadores()->where('jugador_equipo.id_jugador', $data['id_jugador'])->exists()) { + return back()->with('admin_error', 'El jugador ya está asignado a este equipo.'); + } + + $equipo->jugadores()->attach($data['id_jugador'], ['fecha_alta' => now()]); + + return back()->with('admin_msg', 'Jugador asignado correctamente.'); + } + + public function equipoRemoveJugador($id, $id_jugador) + { + $this->checkGeneralAdmin(request()); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403); + } + + $equipo->jugadores()->detach($id_jugador); + + return back()->with('admin_msg', 'Jugador removido del equipo.'); + } + + public function jugadoresSearchAjax(Request $request) + { + $this->checkGeneralAdmin($request); + $q = $request->input('q'); + + $query = Jugador::query(); + + if (session('admin_role') == 2) { + $query->where('id_club_actual', session('admin_id_club')); + } + + if ($q) { + $query->where(function($sub) use ($q) { + $sub->where('nombre', 'like', "%$q%") + ->orWhere('apellido', 'like', "%$q%") + ->orWhere('documento', 'like', "%$q%"); + }); + } + + $jugadores = $query->limit(10)->get(['id_jugador', 'nombre', 'apellido', 'documento']); + + return response()->json($jugadores); + } + + public function jugadoresCategoriaPorEdad(Request $request) + { + $this->checkGeneralAdmin($request); + $fecha = $request->input('fecha'); + if (!$fecha) return response()->json(['categoria' => 'Sin categoría']); + + $anio = \Carbon\Carbon::parse($fecha)->format('Y'); + $edadCategoria = date('Y') - $anio; + + $categoria = \App\Models\Categoria::where('edad_min', '<=', $edadCategoria) + ->where('edad_max', '>=', $edadCategoria) + ->first(); + + return response()->json(['categoria' => $categoria ? $categoria->nombre : 'Sin categoría']); + } + + + // ══════════════════════════════════ + // JUGADORES + // ══════════════════════════════════ + public function jugadoresIndex(Request $request) + { + $this->checkGeneralAdmin($request); + $search = $request->input('q'); + $query = Jugador::with('clubActual') + ->orderByRaw("COALESCE(updated_at, '1970-01-01') DESC") + ->orderByRaw('CAST(id_jugador AS UNSIGNED) DESC'); + + if (session('admin_role') == 2) { + $query->where('id_club_actual', session('admin_id_club')); + } + + if ($search) { + // Tokenizar por espacios. Cada token debe matchear como INICIO de palabra + // dentro de "apellido nombre" — así "Man Adriel" encuentra a MAN ADRIEL pero + // no a ROMAN PEREZ. El DNI sigue aceptando match por substring directo. + $tokens = array_values(array_filter( + array_map( + fn($t) => preg_replace('/[^\p{L}\p{N}]/u', '', $t), + preg_split('/\s+/u', $search) + ), + fn($t) => mb_strlen($t) > 0 + )); + + $query->where(function ($q) use ($tokens, $search) { + $q->where('documento', 'like', '%' . $search . '%'); + + if (!empty($tokens)) { + $q->orWhere(function ($qq) use ($tokens) { + foreach ($tokens as $token) { + $qq->whereRaw( + "LOWER(CONCAT(apellido, ' ', nombre)) REGEXP ?", + ['[[:<:]]' . mb_strtolower($token)] + ); + } + }); + } + }); + } + + $jugadores = $query->paginate(25); + $clubes = session('admin_role') == 1 ? Club::orderBy('nombre')->get() : []; + + return view('admin.jugadores.index', compact('jugadores', 'search', 'clubes')); + } + + public function jugadoresCreate(Request $request) + { + $this->checkGeneralAdmin($request); + $clubes = Club::orderBy('nombre')->get(); + return view('admin.jugadores.form', ['jugador' => null, 'clubes' => $clubes]); + } + + public function jugadoresStore(Request $request) + { + $this->checkGeneralAdmin($request); + $data = $request->validate([ + 'documento' => 'required|string|max:20', + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'fecha_nacimiento' => 'required|date', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'required|integer|exists:clubes,id_club', + ]); + + // Verificar si el DNI ya existe. withTrashed: el índice UNIQUE de la BD considera + // también soft-deleted, así que la validación debe contemplarlos para no caer en error 1062. + $existente = Jugador::withTrashed()->where('documento', $data['documento'])->first(); + if ($existente) { + if ($existente->trashed()) { + return back()->withInput()->withErrors([ + 'documento' => "Este DNI pertenece a un jugador que está en la papelera. Restauralo desde allí o contactá al superadmin." + ]); + } + $clubNombre = $existente->clubActual ? $existente->clubActual->nombre : 'Sin Club'; + return back()->withInput()->withErrors([ + 'documento' => "No se puede registrar al jugador dado que ya pertenece al club $clubNombre." + ]); + } + + if (session('admin_role') == 2) { + $data['id_club_actual'] = session('admin_id_club'); + // Nota: id_club_origen podría ser el mismo o diferente, + // pero el Id del jugador siempre se basa en el de origen. + } + + $data['nombre'] = strtoupper(trim($data['nombre'])); + $data['apellido'] = strtoupper(trim($data['apellido'])); + $data['activo'] = 0; // Se valida luego en /asociate + if (isset($data['fecha_nacimiento'])) { + $data['edad'] = \Carbon\Carbon::parse($data['fecha_nacimiento'])->age; + // $data['categoria'] ya no se asigna, ahora es dinámica + } + + $idClub = $data['id_club_origen']; + $yearFull = \Carbon\Carbon::parse($data['fecha_nacimiento'])->format('Y'); + $data['id_jugador'] = $this->generarIdJugador($idClub, $yearFull); + + Jugador::create($data); + return redirect()->route('admin.jugadores.index')->with('admin_msg', 'Jugador creado correctamente. Puede activarse en /asociate.'); + } + + private function generarIdJugador($idClub, $yearFull) + { + $yearShort = \Carbon\Carbon::parse($yearFull . '-01-01')->format('y'); + $prefix = $idClub . $yearShort; + $secuencia = $this->obtenerSiguienteSecuencia($idClub, $yearFull, $prefix); + + return sprintf('%s%02d', $prefix, $secuencia); + } + + private function obtenerSiguienteSecuencia($idClub, $yearFull, $prefix) + { + // withTrashed: incluir jugadores soft-deleted para no reusar IDs y chocar con la PK. + $ultimoId = (string)Jugador::withTrashed() + ->where('id_jugador', 'LIKE', $prefix . '%') + ->whereRaw("id_jugador REGEXP '^[0-9]+$'") + ->orderByRaw('CAST(id_jugador AS UNSIGNED) DESC') + ->value('id_jugador'); + + $secuencia = 1; + if ($ultimoId && str_starts_with($ultimoId, $prefix)) { + $secuenciaStr = substr($ultimoId, strlen($prefix)); + $secuencia = (int)$secuenciaStr + 1; + } + return $secuencia; + } + + public function jugadoresImport(Request $request) + { + $this->checkGeneralAdmin($request); + $request->validate([ + 'csv_file' => 'required|file|mimes:csv,txt', + 'id_club' => session('admin_role') == 1 ? 'nullable|integer' : 'nullable' + ]); + + $file = $request->file('csv_file'); + $handle = fopen($file->getRealPath(), 'r'); + + $successCount = 0; + $omittedCount = 0; + $errorCount = 0; + $teamAssignedCount = 0; + $errors = []; + + // Determinar Club Target + $targetClubId = session('admin_role') == 2 ? session('admin_id_club') : ($request->input('id_club') ?? 99); + + $currentCategory = null; + $formatType = 'legacy'; // legacy, cab, internal + $localSequences = []; // Cache para evitar duplicados en el mismo loop + + // Leer primeras líneas para detectar formato (el CAB puede empezar con una categoría) + $detectLines = []; + for ($i=0; $i<5; $i++) { + $l = fgets($handle); + if ($l) $detectLines[] = $l; + } + rewind($handle); + + $fullContentSample = implode("\n", $detectLines); + if (str_contains($fullContentSample, 'ESTADO LICENCIA') || str_contains($fullContentSample, 'TIPO') || str_contains($fullContentSample, 'JUGADOR')) { + $formatType = 'cab'; + } elseif (str_contains($fullContentSample, 'DNI;Apellido;Nombre;Fecha Nacimiento')) { + $formatType = 'internal'; + } + + while (($row = fgetcsv($handle, 1000, ";")) !== FALSE) { + if (empty(array_filter($row))) continue; + + // --- DETECCION DE CATEGORIA (Solo CAB) --- + // Si la primera columna tiene texto y el resto está vacío, es una categoría (ej: "PREMINI B;;;;;") + if ($formatType == 'cab' && !empty($row[0]) && empty(array_filter(array_slice($row, 1)))) { + $currentCategory = trim($row[0]); + continue; + } + + // Saltos de cabecera + if ($formatType == 'cab' && $row[0] == 'ESTADO LICENCIA') continue; + if ($formatType == 'internal' && $row[0] == 'DNI') continue; + + try { + $dni = ''; $apellido = ''; $nombre = ''; $fechaNac = ''; + $idClubOrigen = $targetClubId; + $idClubActual = $targetClubId; + $isJugador = true; + + if ($formatType == 'cab') { + // Formato: ESTADO LICENCIA;NIF;NOMBRE;FECHA_ALTA;BAJA;TIPO;FECHA NACIMIENTO;NACIONALIDAD + if (count($row) < 7) continue; + $dni = trim($row[1]); + $fullName = trim($row[2]); // "APELLIDO, NOMBRE" + $tipo = strtoupper(trim($row[5])); + $fechaRaw = trim($row[6]); // d/m/Y + + if ($tipo !== 'JUGADOR') { + $isJugador = false; + } + + if ($isJugador) { + $parts = explode(',', $fullName); + $apellido = trim($parts[0]); + $nombre = isset($parts[1]) ? trim($parts[1]) : ''; + + // Parsear d/m/Y + $dateParts = explode('/', $fechaRaw); + if (count($dateParts) == 3) { + $fechaNac = "{$dateParts[2]}-{$dateParts[1]}-{$dateParts[0]}"; + } + } + } elseif ($formatType == 'internal') { + // Formato: DNI; Apellido; Nombre; Fecha Nacimiento (d/m/Y); ID Club Origen; ID Club Actual; Categoria; Activo + $dni = trim($row[0]); + $apellido = trim($row[1]); + $nombre = trim($row[2]); + $fechaRaw = trim($row[3]); + $idClubOrigen = isset($row[4]) ? (int)trim($row[4]) : $targetClubId; + $idClubActual = isset($row[5]) ? (int)trim($row[5]) : $targetClubId; + + $dateParts = explode('/', $fechaRaw); + if (count($dateParts) == 3) { + $fechaNac = "{$dateParts[2]}-{$dateParts[1]}-{$dateParts[0]}"; + } + } else { + // Formato Legado: DNI; Apellido; Nombre; ddmmaaaa; id_club_origen + if (count($row) < 4) continue; + $dni = trim($row[0]); + + // SEGURIDAD: Si el DNI no es numérico en formato legado, probablemente sea una cabecera o ruido + if (!is_numeric($dni)) continue; + + $apellido = trim($row[1]); + $nombre = trim($row[2]); + $fechaRaw = trim($row[3]); // ddmmaaaa + $idClubOrigen = isset($row[4]) ? (int)trim($row[4]) : $targetClubId; + + if (strlen($fechaRaw) == 8) { + $fechaNac = substr($fechaRaw, 4, 4) . "-" . substr($fechaRaw, 2, 2) . "-" . substr($fechaRaw, 0, 2); + } + } + + if (!$isJugador || !$dni || !$apellido || !$nombre || !$fechaNac) continue; + + // Verificar existencia + $jugador = Jugador::where('documento', $dni)->first(); + $anioNac = date('Y', strtotime($fechaNac)); + + $data = [ + 'documento' => $dni, + 'apellido' => strtoupper(trim($apellido)), + 'nombre' => strtoupper(trim($nombre)), + 'fecha_nacimiento' => $fechaNac, + 'id_club_origen' => $idClubOrigen, + 'id_club_actual' => $idClubActual, + 'edad' => \Carbon\Carbon::parse($fechaNac)->age, + ]; + + if (!$jugador) { + // Generar ID con cache local para evitar colisiones en el mismo loop + $prefix = $idClubOrigen . date('y', strtotime($fechaNac)); + if (!isset($localSequences[$prefix])) { + // Obtener la base inicial de la BDD + $localSequences[$prefix] = $this->obtenerSiguienteSecuencia($idClubOrigen, $anioNac, $prefix); + } else { + $localSequences[$prefix]++; + } + + $data['id_jugador'] = sprintf('%s%02d', $prefix, $localSequences[$prefix]); + $data['activo'] = 0; + $jugador = Jugador::create($data); + $successCount++; + } else { + // El usuario prefiere NO pisar datos si el jugador ya existe + $omittedCount++; + } + + // --- MATCHING DE EQUIPO --- + if ($currentCategory && $jugador) { + $equipo = Equipo::where('id_club', $idClubActual) + ->where('categoria', 'LIKE', $currentCategory) + ->first(); + if ($equipo) { + // Evitar duplicados en pivot + if (!$equipo->jugadores()->where('jugador_equipo.id_jugador', $jugador->id_jugador)->exists()) { + $equipo->jugadores()->attach($jugador->id_jugador, ['fecha_alta' => now()]); + $teamAssignedCount++; + } + } + } + + } catch (\Exception $e) { + $errorCount++; + $errors[] = "Error en fila DNI $dni: " . $e->getMessage(); + } + } + fclose($handle); + + $msg = "Importación finalizada ({$formatType}). $successCount nuevos creados, $omittedCount ya registrados (no modificados)."; + if ($teamAssignedCount > 0) $msg .= " $teamAssignedCount asignaciones a equipos realizadas."; + + if ($errorCount > 0) { + return redirect()->route('admin.jugadores.index')->with('admin_msg', $msg)->with('admin_error', implode(" | ", array_slice($errors, 0, 5))); + } + + return redirect()->route('admin.jugadores.index')->with('admin_msg', $msg); + } + + public function jugadoresExport(Request $request) + { + $this->checkGeneralAdmin($request); + + $headers = [ + "Content-type" => "text/csv; charset=UTF-8", + "Content-Disposition" => "attachment; filename=jugadores_" . date('Ymd_His') . ".csv", + "Pragma" => "no-cache", + "Cache-Control" => "must-revalidate, post-check=0, pre-check=0", + "Expires" => "0" + ]; + + $callback = function() { + $file = fopen('php://output', 'w'); + // Añadir BOM para Excel (UTF-8) + fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); + + // Cabeceras + fputcsv($file, [ + 'DNI', 'Apellido', 'Nombre', 'Fecha Nacimiento', 'ID Club Origen', 'ID Club Actual', 'Categoria', 'Activo' + ], ";"); + + $query = Jugador::orderBy('apellido'); + + if (session('admin_role') == 2) { + $query->where('id_club_actual', session('admin_id_club')); + } + + $jugadores = $query->get(); + + foreach ($jugadores as $j) { + fputcsv($file, [ + $j->documento, + $j->apellido, + $j->nombre, + $j->fecha_nacimiento ? $j->fecha_nacimiento->format('d/m/Y') : '', + $j->id_club_origen ?? 99, + $j->id_club_actual ?? 99, + $j->categoria_calculada, + $j->activo ? 'SI' : 'NO' + ], ";"); + } + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } + + public function jugadoresEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $jugador = Jugador::findOrFail($id); + + if (session('admin_role') == 2 && $jugador->id_club_actual != session('admin_id_club')) { + abort(403, 'No puedes editar un jugador que no pertenece a tu club.'); + } + + $clubes = session('admin_role') == 1 ? Club::orderBy('nombre')->get() : []; + return view('admin.jugadores.form', compact('jugador', 'clubes')); + } + + public function jugadoresUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $jugador = Jugador::findOrFail($id); + + if (session('admin_role') == 2 && $jugador->id_club_actual != session('admin_id_club')) { + abort(403, 'No puedes editar un jugador que no pertenece a tu club.'); + } + + $data = $request->validate([ + 'documento' => 'required|string|max:20|unique:jugadores,documento,' . $id . ',id_jugador', + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'fecha_nacimiento' => 'required|date', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'nullable|integer|exists:clubes,id_club', + ]); + + if (session('admin_role') == 2) { + $data['id_club_actual'] = session('admin_id_club'); // Forzamos a no cambiarlo + } + + if (isset($data['fecha_nacimiento'])) { + $data['edad'] = \Carbon\Carbon::parse($data['fecha_nacimiento'])->age; + } + + $jugador->update($data); + return redirect()->route('admin.jugadores.index')->with('admin_msg', 'Jugador actualizado correctamente.'); + } + + public function jugadoresDestroy(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $jugador = Jugador::findOrFail($id); + + if (session('admin_role') == 2 && $jugador->id_club_actual != session('admin_id_club')) { + abort(403, 'No tienes permiso para eliminar este jugador.'); + } + + $jugador->delete(); + return redirect()->route('admin.jugadores.index')->with('admin_msg', 'Jugador eliminado correctamente.'); + } + + // ══════════════════════════════════ + // EVENTOS + // ══════════════════════════════════ + public function escanearQr(Request $request) + { + $this->checkGeneralAdmin($request); + + $query = Evento::orderByRaw(" + CAST(SUBSTRING_INDEX(nombre_evento, 'PARTIDO ', -1) AS UNSIGNED) ASC, + nombre_evento ASC, + fecha_evento ASC, + hora_inicio ASC + "); + + $ahora = \Carbon\Carbon::now(); + $query->where(function($q) use ($ahora) { + $q->where(function($sub) { + $sub->whereNull('marcador_local') + ->orWhereNull('marcador_visitante'); + }) + ->orWhere('fecha_evento', '>', $ahora->toDateString()) + ->orWhere(function($q2) use ($ahora) { + $q2->where('fecha_evento', '=', $ahora->toDateString()) + ->where('hora_fin', '>', $ahora->toTimeString()); + }); + }); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $query->where(function ($q) use ($idClub) { + $q->whereHas('equipoLocal', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + })->orWhereHas('equipoVisitante', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + }); + }); + } + + $eventos = $query->get(); + return view('admin.escanear_qr', compact('eventos')); + } + + public function eventosIndex(Request $request) + { + $this->checkGeneralAdmin($request); + + $estado = $request->get('estado', 'todos'); + $query = Evento::with(['equipoLocal.club', 'equipoVisitante.club'])->orderBy('fecha_evento', 'desc'); + + $tz = 'America/Argentina/Buenos_Aires'; + $ahora = \Carbon\Carbon::now($tz); + + // Filtro por estado + if ($estado == 'finalizados') { + $query->whereNotNull('marcador_local') + ->whereNotNull('marcador_visitante') + ->where(function($q) use ($ahora) { + $q->where('fecha_evento', '<', $ahora->toDateString()) + ->orWhere(function($q2) use ($ahora) { + $q2->where('fecha_evento', '=', $ahora->toDateString()) + ->where('hora_fin', '<=', $ahora->toTimeString()); + }); + }); + } elseif ($estado == 'pendientes') { + $query->where(function($q) use ($ahora) { + $q->whereNull('marcador_local') + ->orWhereNull('marcador_visitante') + ->orWhere('fecha_evento', '>', $ahora->toDateString()) + ->orWhere(function($q2) use ($ahora) { + $q2->where('fecha_evento', '=', $ahora->toDateString()) + ->where('hora_fin', '>', $ahora->toTimeString()); + }); + }); + } + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $query->where(function($q) use ($idClub) { + $q->whereHas('equipoLocal', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + })->orWhereHas('equipoVisitante', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + }); + }); + } + + $eventos = $query->orderBy('id_evento', 'desc')->get(); + return view('admin.eventos.index', compact('eventos', 'estado')); + } + + public function eventosCreate(Request $request) + { + $this->checkSuperAdmin($request); + $equipos = Equipo::with('club')->get(); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + $torneoEquipos = DB::table('torneo_equipo')->get(); + return view('admin.eventos.form', ['evento' => null, 'equipos' => $equipos, 'torneos' => $torneos, 'torneoEquipos' => $torneoEquipos]); + } + + public function eventosStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre_evento' => 'nullable|string|max:200', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + 'fecha_evento' => 'required|date', + 'hora_inicio' => 'required', + 'hora_fin' => 'required', + 'sede' => 'required|string|max:200', + 'id_equipo_local' => 'required|integer|exists:equipos,id_equipo', + 'id_equipo_visitante' => 'required|integer|exists:equipos,id_equipo', + 'precio' => 'nullable|numeric|min:0', + 'marcador_local' => 'nullable|integer|min:0', + 'marcador_visitante' => 'nullable|integer|min:0', + ]); + + // Validaciones Deportivas + $local = Equipo::findOrFail($data['id_equipo_local']); + $visit = Equipo::findOrFail($data['id_equipo_visitante']); + + if ($local->categoria != $visit->categoria) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer a la misma categoría.']); + } + + if (!empty($data['id_torneo'])) { + $torneo = \App\Models\Torneo::findOrFail($data['id_torneo']); + $localInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_local'])->first(); + $visitInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_visitante'])->first(); + + if (!$localInTorneo || !$visitInTorneo) { + return back()->withInput()->withErrors(['id_torneo' => 'Uno o ambos equipos no están inscritos en este torneo.']); + } + + if ($localInTorneo->pivot->grupo != $visitInTorneo->pivot->grupo) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer al mismo grupo dentro del torneo.']); + } + } + + // Autogenerar ID + $data['id_evento'] = bin2hex(random_bytes(4)); // 8 caracteres + + if (empty($data['nombre_evento'])) { + $grupoName = 'General'; + if (!empty($data['id_torneo'])) { + $rel = DB::table('torneo_equipo') + ->where('id_torneo', $data['id_torneo']) + ->where('id_equipo', $data['id_equipo_local']) + ->first(); + if ($rel) $grupoName = $rel->grupo ?? 'General'; + } + $data['nombre_evento'] = ($local->club->nombre ?? 'Local') . ' vs ' . ($visit->club->nombre ?? 'Visitante') . " ({$grupoName})"; + } + + Evento::create($data); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Evento creado correctamente.'); + } + + public function eventosEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $evento = Evento::findOrFail($id); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $isLocal = $evento->equipoLocal && $evento->equipoLocal->id_club == $idClub; + $isVisitante = $evento->equipoVisitante && $evento->equipoVisitante->id_club == $idClub; + if (!$isLocal && !$isVisitante) { + abort(403, 'No tienes permiso para editar este evento.'); + } + } + $equipos = Equipo::with('club')->get(); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + $torneoEquipos = DB::table('torneo_equipo')->get(); + return view('admin.eventos.form', compact('evento', 'equipos', 'torneos', 'torneoEquipos')); + } + + public function eventosUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $evento = Evento::findOrFail($id); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $isLocal = $evento->equipoLocal && $evento->equipoLocal->id_club == $idClub; + $isVisitante = $evento->equipoVisitante && $evento->equipoVisitante->id_club == $idClub; + if (!$isLocal && !$isVisitante) { + abort(403, 'No tienes permiso para editar este evento.'); + } + + // Club admins only edit limite_qr_jugador + $data = $request->validate([ + 'limite_qr_jugador' => 'required|integer|min:0', + ]); + $evento->update(['limite_qr_jugador' => $data['limite_qr_jugador']]); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Pase de QRs para el evento actualizado.'); + } + + // Si es edición, restringimos los campos permitidos según solicitud del usuario + if ($id) { + $data = $request->validate([ + 'fecha_evento' => 'required|date', + 'hora_inicio' => 'required', + 'hora_fin' => 'required', + 'limite_qr_jugador' => 'nullable|integer|min:0', + 'marcador_local' => 'nullable|integer|min:0', + 'marcador_visitante' => 'nullable|integer|min:0', + 'nombre_evento' => 'nullable|string|max:200', + ]); + + // Conservar valores que no deberían cambiar para que la validación posterior (deporte) no falle + $data['id_torneo'] = $evento->id_torneo; + $data['id_equipo_local'] = $evento->id_equipo_local; + $data['id_equipo_visitante'] = $evento->id_equipo_visitante; + + // Si el nombre viene vacío en edición, también lo autogeneramos? + // El usuario pidió que se setee automáticamente al no poner nada. + if (empty($data['nombre_evento'])) { + $local = Equipo::findOrFail($data['id_equipo_local']); + $visit = Equipo::findOrFail($data['id_equipo_visitante']); + $grupoName = 'General'; + if (!empty($data['id_torneo'])) { + $rel = DB::table('torneo_equipo') + ->where('id_torneo', $data['id_torneo']) + ->where('id_equipo', $data['id_equipo_local']) + ->first(); + if ($rel) $grupoName = $rel->grupo ?? 'General'; + } + $data['nombre_evento'] = ($local->club->nombre ?? 'Local') . ' vs ' . ($visit->club->nombre ?? 'Visitante') . " ({$grupoName})"; + } + + $data['sede'] = $evento->sede; + $data['precio'] = $evento->precio; + } else { + $data = $request->validate([ + 'nombre_evento' => 'nullable|string|max:200', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + 'fecha_evento' => 'required|date', + 'hora_inicio' => 'required', + 'hora_fin' => 'required', + 'sede' => 'nullable|string|max:200', + 'id_equipo_local' => 'nullable|integer|exists:equipos,id_equipo', + 'id_equipo_visitante' => 'nullable|integer|exists:equipos,id_equipo', + 'precio' => 'nullable|numeric|min:0', + 'limite_qr_jugador' => 'nullable|integer|min:0', + 'marcador_local' => 'nullable|integer|min:0', + 'marcador_visitante' => 'nullable|integer|min:0', + ]); + } + + // Validaciones Deportivas + if ($data['id_equipo_local'] && $data['id_equipo_visitante']) { + $local = Equipo::findOrFail($data['id_equipo_local']); + $visit = Equipo::findOrFail($data['id_equipo_visitante']); + + if ($local->categoria != $visit->categoria) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer a la misma categoría.']); + } + + if (!empty($data['id_torneo'])) { + $torneo = \App\Models\Torneo::findOrFail($data['id_torneo']); + $localInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_local'])->first(); + $visitInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_visitante'])->first(); + + if (!$localInTorneo || !$visitInTorneo) { + return back()->withInput()->withErrors(['id_torneo' => 'Uno o ambos equipos no están inscritos en este torneo.']); + } + + if ($localInTorneo->pivot->grupo != $visitInTorneo->pivot->grupo) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer al mismo grupo dentro del torneo.']); + } + } + + // Autogenerar Nombre si es nulo + if (empty($data['nombre_evento'])) { + $data['nombre_evento'] = ($local->club->nombre ?? 'Local') . ' vs ' . ($visit->club->nombre ?? 'Visitante'); + } + } + + $evento->update($data); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Evento actualizado correctamente.'); + } + + public function eventosDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $evento = Evento::findOrFail($id); + + // Al eliminar un evento, también eliminamos sus QRs para que no queden "huérfanos" + $evento->qrCodes()->delete(); + + $evento->delete(); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Evento eliminado correctamente.'); + } + + // ══════════════════════════════════ + // PROMOCIONES / LUGARES + // ══════════════════════════════════ + public function promocionesIndex(Request $request) + { + $this->checkSuperAdmin($request); + $promociones = Promocion::withCount('promoQrs')->orderBy('id', 'desc')->get(); + return view('admin.promociones.index', compact('promociones')); + } + + public function promocionesCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.promociones.form', ['promocion' => null]); + } + + public function promocionesStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'direccion' => 'required|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen_file' => 'nullable|image|max:2048', + ]); + + if ($request->hasFile('imagen_file')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'promos'); + $data['imagen'] = $path; + } + unset($data['imagen_file']); + + Promocion::create($data); + return redirect()->route('admin.promociones.index')->with('admin_msg', 'Promoción creada correctamente.'); + } + + public function promocionesEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $promocion = Promocion::findOrFail($id); + return view('admin.promociones.form', compact('promocion')); + } + + public function promocionesUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $promocion = Promocion::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'direccion' => 'required|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen_file' => 'nullable|image|max:2048', + ]); + + if ($request->hasFile('imagen_file')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'promos'); + $data['imagen'] = $path; + } + unset($data['imagen_file']); + + $promocion->update($data); + return redirect()->route('admin.promociones.index')->with('admin_msg', 'Promoción actualizada correctamente.'); + } + + public function promocionesDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $promocion = Promocion::findOrFail($id); + $promocion->delete(); + return redirect()->route('admin.promociones.index')->with('admin_msg', 'Promoción eliminada correctamente.'); + } + + // ══════════════════════════════════ + // NOTICIAS + // ══════════════════════════════════ + public function noticiasIndex(Request $request) + { + $this->checkSuperAdmin($request); + $noticias = Noticia::orderBy('fecha', 'desc')->orderBy('id', 'desc')->get(); + return view('admin.noticias.index', compact('noticias')); + } + + public function noticiasCreate(Request $request) + { + $this->checkSuperAdmin($request); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + return view('admin.noticias.form', ['noticia' => null, 'torneos' => $torneos]); + } + + public function noticiasStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'titulo' => 'required|string|max:200', + 'contenido' => 'required', + 'imagen_file' => 'nullable|image|max:5120', + 'categoria' => 'nullable|string|max:50', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + ]); + + if ($request->hasFile('imagen_file')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'noticias'); + $data['imagen'] = 'storage/' . $path; + } + unset($data['imagen_file']); + + $data['fecha'] = now(); + Noticia::create($data); + return redirect()->route('admin.noticias.index')->with('admin_msg', 'Noticia creada correctamente.'); + } + + public function noticiasEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $noticia = Noticia::findOrFail($id); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + return view('admin.noticias.form', compact('noticia', 'torneos')); + } + + public function noticiasUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $noticia = Noticia::findOrFail($id); + $data = $request->validate([ + 'titulo' => 'required|string|max:200', + 'contenido' => 'required', + 'imagen_file' => 'nullable|image|max:5120', + 'categoria' => 'nullable|string|max:50', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + ]); + + if ($request->hasFile('imagen_file')) { + // Eliminar imagen anterior si existe + if ($noticia->imagen && !str_starts_with($noticia->imagen, 'http') && file_exists(public_path($noticia->imagen))) { + @unlink(public_path($noticia->imagen)); + } + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'noticias'); + $data['imagen'] = 'storage/' . $path; + } + unset($data['imagen_file']); + + $noticia->update($data); + return redirect()->route('admin.noticias.index')->with('admin_msg', 'Noticia actualizada correctamente.'); + } + + public function noticiasDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $noticia = Noticia::findOrFail($id); + $noticia->delete(); + return redirect()->route('admin.noticias.index')->with('admin_msg', 'Noticia eliminada correctamente.'); + } + + // ══════════════════════════════════ + // ESCANEAR / VALIDAR QR + // ══════════════════════════════════ + public function validarQr(Request $request) + { + $this->checkGeneralAdmin($request); + $id_qr = $request->input('id_qr'); + $id_evento = $request->input('id_evento'); + + if (!$id_qr) { + return response()->json(['valid' => false, 'message' => 'QR inválido.']); + } + + $qr = QrCode::with('evento', 'jugador', 'aficionado')->where('id_qr', $id_qr)->first(); + + if (!$qr) { + return response()->json(['valid' => false, 'message' => 'QR no encontrado.']); + } + + // Si se seleccionó un evento, exigir que coincida + if ($id_evento && (string)$qr->id_evento !== (string)$id_evento) { + return response()->json(['valid' => false, 'message' => 'Este QR corresponde a otro evento.']); + } + + // Verificar vigencia del evento + if ($qr->evento) { + $ahora = \Carbon\Carbon::now(); + + $f = ($qr->evento->fecha_evento instanceof \Carbon\Carbon) ? $qr->evento->fecha_evento->format('Y-m-d') : substr($qr->evento->fecha_evento, 0, 10); + $h1 = ($qr->evento->hora_inicio instanceof \Carbon\Carbon) ? $qr->evento->hora_inicio->format('H:i:s') : $qr->evento->hora_inicio; + $h2 = ($qr->evento->hora_fin instanceof \Carbon\Carbon) ? $qr->evento->hora_fin->format('H:i:s') : $qr->evento->hora_fin; + + $inicio = \Carbon\Carbon::parse("$f $h1"); + $fin = \Carbon\Carbon::parse("$f $h2"); + + if ($fin->lessThanOrEqualTo($inicio)) { + $fin->addDay(); + } + + if ($ahora < $inicio) { + return response()->json(['valid' => false, 'message' => '⏳ El evento todavía no comenzó.']); + } + if ($ahora > $fin) { + return response()->json(['valid' => false, 'message' => 'Evento finalizado, QR inválido.']); + } + } + + // Verificar escaneos restantes + if ((int)$qr->escaneos_restantes <= 0) { + return response()->json(['valid' => false, 'message' => '❌ QR ya utilizado.']); + } + + // Decrementar escaneos (concurrency-safe via DB) + $affected = \Illuminate\Support\Facades\DB::table('qr_codes') + ->where('id_qr', $id_qr) + ->where('escaneos_restantes', '>', 0) + ->when($id_evento, function ($query) use ($id_evento) { + return $query->where('id_evento', $id_evento); + }) + ->decrement('escaneos_restantes'); + + if ($affected === 0) { + return response()->json(['valid' => false, 'message' => '❌ QR ya utilizado.']); + } + + // Info del titular según tipo de QR + $titular = ''; + $categoriaNombre = ''; + + if ($qr->jugador) { + $categoriaNombre = $qr->jugador->categoria_calculada; + } + + if ($qr->tipo_qr === 'invitado' && $qr->jugador) { + $titular = 'Jugador: ' . $qr->jugador->nombre . ' ' . $qr->jugador->apellido . ' (' . $categoriaNombre . ')'; + } elseif ($qr->tipo_qr === 'publico') { + if ($qr->aficionado) { + $titular = 'Aficionado: ' . $qr->aficionado->nombre . ' ' . $qr->aficionado->apellido; + } else { + $titular = 'QR de entrada pública (sin referencia)'; + } + } else { + $titular = $qr->jugador + ? $qr->jugador->nombre . ' ' . $qr->jugador->apellido . ' (' . $categoriaNombre . ')' + : ($qr->aficionado ? $qr->aficionado->nombre . ' ' . $qr->aficionado->apellido : 'Desconocido'); + } + + $mensajeValido = '✅ Acceso válido — Jugador del evento'; + + if ($qr->tipo_qr === 'libre_50') { + $mensajeValido = '✅ Tenés descuento en la entrada, 50% (Jugador Categoría Libre)'; + } + + return response()->json([ + 'valid' => true, + 'message' => $mensajeValido, + 'data' => [ + 'evento' => $qr->evento ? $qr->evento->nombre_evento : $qr->id_evento, + 'titular' => $titular, + 'categoria' => $categoriaNombre, + 'tipo' => $qr->tipo_qr, + 'restantes' => ($qr->escaneos_restantes > 0 ? $qr->escaneos_restantes - 1 : 0), + ], + ]); + } + + // ══════════════════════════════════ + // CONFIGURACIÓN GENERAL + // ══════════════════════════════════ + public function settingsIndex(Request $request) + { + $this->checkSuperAdmin($request); + $diasExpiracion = \App\Models\Configuracion::get('dias_expiracion_eventos', 30); + $backupFreq = \App\Models\Configuracion::get('backup_frequency', 'daily'); + $emailReportes = \App\Models\Configuracion::get('email_reportes', 'asociados@onapb.com'); + $lastRun = \App\Models\Configuracion::get('last_scheduler_run', 'Nunca detectado'); + + return view('admin.settings', compact('diasExpiracion', 'backupFreq', 'lastRun', 'emailReportes')); + } + + public function settingsUpdate(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'dias_expiracion_eventos' => 'required|integer|min:1', + 'backup_frequency' => 'required|string|in:daily,weekly,monthly', + 'email_reportes' => 'required|email', + ]); + + \App\Models\Configuracion::set('dias_expiracion_eventos', $data['dias_expiracion_eventos'], 'Días de antigüedad para borrar eventos y QRs'); + \App\Models\Configuracion::set('backup_frequency', $data['backup_frequency'], 'Frecuencia de backups automáticos (daily, weekly, monthly)'); + \App\Models\Configuracion::set('email_reportes', $data['email_reportes'], 'Email principal para recibir reportes del sistema'); + + return back()->with('admin_msg', 'Configuración actualizada correctamente.'); + } + + public function runManualTask(Request $request) + { + $this->checkSuperAdmin($request); + $command = $request->input('command'); + + try { + switch ($command) { + case 'cleanup': + \Illuminate\Support\Facades\Artisan::call('app:cleanup-old-events'); + $msg = '✅ Tarea de limpieza ejecutada.'; + break; + case 'backup': + // Usamos backup:run para forzar un backup completo + \Illuminate\Support\Facades\Artisan::call('backup:run'); + $msg = '✅ Proceso de backup iniciado.'; + break; + case 'report': + \Illuminate\Support\Facades\Artisan::call('reportes:semanal'); + $msg = '✅ Informe semanal enviado.'; + break; + default: + throw new \Exception('Comando no reconocido.'); + } + + return back()->with('admin_msg', $msg . ' (Salida: ' . \Illuminate\Support\Facades\Artisan::output() . ')'); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error("Error ejecutando comando manual: " . $e->getMessage()); + return back()->with('admin_error', 'Error al ejecutar tarea: ' . $e->getMessage()); + } + } + + // ══════════════════════════════════ + // SPONSORS + // ══════════════════════════════════ + public function sponsorsIndex(Request $request) + { + $this->checkSuperAdmin($request); + $sponsors = Sponsor::orderBy('orden')->latest()->get(); + return view('admin.sponsors.index', compact('sponsors')); + } + + public function sponsorsCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.sponsors.form'); + } + + public function sponsorsStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'imagen' => 'required|image|max:5120', + 'url' => 'nullable|url|max:255', + 'activo' => 'nullable|boolean', + 'orden' => 'nullable|integer', + ]); + + if ($request->hasFile('imagen')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'sponsors'); + $data['imagen'] = 'storage/' . $path; + } + + $data['activo'] = $request->has('activo'); + $data['orden'] = $data['orden'] ?? 0; + + Sponsor::create($data); + + return redirect()->route('admin.sponsors.index')->with('admin_msg', 'Sponsor creado correctamente.'); + } + + public function sponsorsEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $sponsor = Sponsor::findOrFail($id); + return view('admin.sponsors.form', compact('sponsor')); + } + + public function sponsorsUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $sponsor = Sponsor::findOrFail($id); + + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'imagen' => 'nullable|image|max:5120', + 'url' => 'nullable|url|max:255', + 'activo' => 'nullable|boolean', + 'orden' => 'nullable|integer', + ]); + + if ($request->hasFile('imagen')) { + // Eliminar imagen anterior si existe + if ($sponsor->imagen && file_exists(public_path($sponsor->imagen))) { + @unlink(public_path($sponsor->imagen)); + } + + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'sponsors'); + $data['imagen'] = 'storage/' . $path; + } + + $data['activo'] = $request->has('activo'); + $data['orden'] = $data['orden'] ?? 0; + + $sponsor->update($data); + + return redirect()->route('admin.sponsors.index')->with('admin_msg', 'Sponsor actualizado correctamente.'); + } + + public function sponsorsDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $sponsor = Sponsor::findOrFail($id); + + if ($sponsor->imagen && file_exists(public_path($sponsor->imagen))) { + @unlink(public_path($sponsor->imagen)); + } + + $sponsor->delete(); + + return redirect()->route('admin.sponsors.index')->with('admin_msg', 'Sponsor eliminado correctamente.'); + } + + // ══════════════════════════════════ + // TORNEOS + // ══════════════════════════════════ + public function torneosIndex(Request $request) + { + $this->checkSuperAdmin($request); + $torneos = \App\Models\Torneo::withCount('equipos')->latest()->get(); + return view('admin.torneos.index', compact('torneos')); + } + + public function torneosCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.torneos.form', ['torneo' => null]); + } + + public function torneosStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'fecha_inicio' => 'nullable|date', + 'fecha_fin' => 'nullable|date', + ]); + \App\Models\Torneo::create($data); + return redirect()->route('admin.torneos.index')->with('admin_msg', 'Torneo creado correctamente.'); + } + + public function torneosEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::with('equipos.club')->findOrFail($id); + $clubes = Club::with('equipos')->orderBy('nombre')->get(); + return view('admin.torneos.form', compact('torneo', 'clubes')); + } + + public function torneosUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'fecha_inicio' => 'nullable|date', + 'fecha_fin' => 'nullable|date', + ]); + $torneo->update($data); + return redirect()->route('admin.torneos.index')->with('admin_msg', 'Torneo actualizado correctamente.'); + } + + public function torneosDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::findOrFail($id); + $torneo->delete(); + return redirect()->route('admin.torneos.index')->with('admin_msg', 'Torneo eliminado correctamente.'); + } + + public function torneoAddEquipo(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::findOrFail($id); + $data = $request->validate([ + 'id_equipo' => 'required|integer|exists:equipos,id_equipo', + 'grupo' => 'nullable|string|max:50', + ]); + + if ($torneo->equipos()->where('torneo_equipo.id_equipo', $data['id_equipo'])->exists()) { + return back()->with('admin_error', 'El equipo ya está asignado a este torneo.'); + } + + $torneo->equipos()->attach($data['id_equipo'], ['grupo' => $data['grupo']]); + return back()->with('admin_msg', 'Equipo asignado al torneo correctamente.'); + } + + public function torneoRemoveEquipo($id, $id_equipo) + { + $this->checkSuperAdmin(request()); + $torneo = \App\Models\Torneo::findOrFail($id); + $torneo->equipos()->detach($id_equipo); + return back()->with('admin_msg', 'Equipo removido del torneo.'); + } + + public function eventosStats($id) + { + $this->checkGeneralAdmin(request()); + $evento = Evento::with(['equipoLocal.jugadores', 'equipoVisitante.jugadores'])->findOrFail($id); + + // Restricción para Administradores de Club: Solo partidos donde participa su club + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + if ($evento->equipoLocal->id_club != $idClub && $evento->equipoVisitante->id_club != $idClub) { + abort(403, 'No tienes permiso para gestionar estadísticas de este partido.'); + } + } + + $stats = \App\Models\EventoJugador::where('id_evento', $id)->get()->keyBy('id_jugador'); + return view('admin.eventos.stats', compact('evento', 'stats')); + } + + public function eventosStatsStore(Request $request, $id) + { + $this->checkGeneralAdmin($request); + + // Validación de seguridad para Edición + if (session('admin_role') == 2) { + $evento = Evento::findOrFail($id); + $idClub = session('admin_id_club'); + if ($evento->equipoLocal->id_club != $idClub && $evento->equipoVisitante->id_club != $idClub) { + abort(403, 'No tienes permiso para editar estadísticas de este partido.'); + } + } + + $request->validate([ + 'stats' => 'required|array', + 'stats.*.puntos' => 'required|integer|min:0', + 'stats.*.faltas' => 'required|integer|min:0|max:5', + ]); + + foreach ($request->stats as $id_jugador => $vals) { + // Si es Admin de Club, solo puede guardar sus propios jugadores + if (session('admin_role') == 2) { + $jugador = Jugador::find($id_jugador); + if (!$jugador || $jugador->id_club_actual != session('admin_id_club')) { + continue; + } + } + + \App\Models\EventoJugador::updateOrCreate( + ['id_evento' => $id, 'id_jugador' => $id_jugador], + ['puntos' => $vals['puntos'], 'faltas' => $vals['faltas']] + ); + } + + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Estadísticas guardadas correctamente.'); + } +} diff --git a/app/Http/Controllers/AdminUserController.php b/app/Http/Controllers/AdminUserController.php new file mode 100644 index 0000000..8cf1ca3 --- /dev/null +++ b/app/Http/Controllers/AdminUserController.php @@ -0,0 +1,55 @@ +json($users); + } + + public function show($id) + { + $user = AdminUser::findOrFail($id); + return response()->json($user); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'username' => 'required|string|max:50|unique:admin_users', + 'password' => 'required|string', + 'role' => 'nullable|integer', + ]); + $data['password'] = bcrypt($data['password']); + $user = AdminUser::create($data); + return response()->json($user, 201); + } + + public function update(Request $request, $id) + { + $user = AdminUser::findOrFail($id); + $data = $request->validate([ + 'username' => 'sometimes|string|max:50|unique:admin_users,username,' . $id, + 'password' => 'sometimes|string', + 'role' => 'sometimes|integer', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $user->update($data); + return response()->json($user); + } + + public function destroy($id) + { + $user = AdminUser::findOrFail($id); + $user->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/AficionadoController.php b/app/Http/Controllers/AficionadoController.php new file mode 100644 index 0000000..f3eadd7 --- /dev/null +++ b/app/Http/Controllers/AficionadoController.php @@ -0,0 +1,67 @@ +json($aficionados); + } + + public function show($id) + { + $aficionado = Aficionado::findOrFail($id); + return response()->json($aficionado); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'dni' => 'required|string|max:20|unique:aficionados', + 'fecha_nacimiento' => 'nullable|date', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'localidad' => 'nullable|string|max:100', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $aficionado = Aficionado::create($data); + return response()->json($aficionado, 201); + } + + public function update(Request $request, $id) + { + $aficionado = Aficionado::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'sometimes|string|max:100', + 'apellido' => 'sometimes|string|max:100', + 'dni' => 'sometimes|string|max:20|unique:aficionados,dni,' . $id . ',id_aficionado', + 'fecha_nacimiento' => 'nullable|date', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'localidad' => 'nullable|string|max:100', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $aficionado->update($data); + return response()->json($aficionado); + } + + public function destroy($id) + { + $aficionado = Aficionado::findOrFail($id); + $aficionado->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..a801d3a --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,411 @@ +post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ + 'secret' => config('services.turnstile.secret_key'), + 'response' => $token, + 'remoteip' => request()->ip(), + ]); + + return $response->successful() && $response->json('success'); + } + + public function login(Request $request) + { + $tipo = $request->input('tipo'); + + if ($tipo === 'admin') { + return $this->loginAdmin($request); + } + + return $this->loginPlayer($request); + } + + public function loginPlayer(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('login_error', 'Error de verificación de seguridad (Turnstile).')->with('login_tab', 'player'); + } + + $dni = $request->input('dni'); + $password = $request->input('password'); + + $jugador = Jugador::where('documento', $dni)->where('activo', true)->first(); + + if ($jugador && $jugador->password && Hash::check($password, $jugador->password)) { + $request->session()->put('user_logged_in', true); + $request->session()->put('user_tipo', 'jugador'); + $request->session()->put('user_id', $jugador->id_jugador); + $request->session()->put('user_name', $jugador->nombre . ' ' . $jugador->apellido); + $request->session()->put('user_documento', $jugador->documento); + $request->session()->put('user_ultimo_acceso', time()); + + return redirect()->intended('/'); + } + + $aficionado = Aficionado::where('dni', $dni)->first(); + + if ($aficionado && $aficionado->password && Hash::check($password, $aficionado->password)) { + $request->session()->put('user_logged_in', true); + $request->session()->put('user_tipo', 'aficionado'); + $request->session()->put('user_id', $aficionado->id_aficionado); + $request->session()->put('user_name', $aficionado->nombre . ' ' . $aficionado->apellido); + $request->session()->put('user_documento', $aficionado->dni); + $request->session()->put('user_ultimo_acceso', time()); + + return redirect()->intended('/'); + } + + return back()->with('login_error', 'DNI o contraseña incorrectos')->with('login_tab', 'player'); + } + + public function loginAdmin(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('login_error', 'Error de verificación de seguridad (Turnstile).')->with('login_tab', 'admin'); + } + + $username = $request->input('username'); + $password = $request->input('password'); + + $admin = AdminUser::whereRaw('BINARY `username` = ?', [$username])->first(); + + if ($admin && Hash::check($password, $admin->password)) { + $request->session()->put('admin_logged_in', true); + $request->session()->put('admin_id', $admin->id); + $request->session()->put('admin_username', $admin->username); + $request->session()->put('admin_role', $admin->role); + $request->session()->put('admin_id_club', $admin->id_club); + + if ($admin->id_club && $admin->club) { + $request->session()->put('admin_club_nombre', $admin->club->nombre); + } + + $request->session()->put('ultimo_acceso', time()); + + return redirect()->intended('/'); + } + + return back()->with('login_error', 'Usuario o contraseña incorrectos')->with('login_tab', 'admin'); + } + + public function logout(Request $request) + { + $isAdmin = $request->session()->get('admin_logged_in'); + + if ($isAdmin) { + $request->session()->forget(['admin_logged_in', 'admin_id', 'admin_username', 'admin_role', 'ultimo_acceso']); + $msg = 'Sesión de administrador cerrada correctamente.'; + } else { + $request->session()->forget(['user_logged_in', 'user_tipo', 'user_id', 'user_name', 'user_documento', 'user_ultimo_acceso']); + $msg = 'Sesión cerrada correctamente.'; + } + + return redirect('/?logout_msg=' . urlencode($msg)); + } + + public function showLoginForm() + { + return view('welcome'); + } + + public function recuperar(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('mensaje', '⚠️ Error de verificación de seguridad (Captcha).'); + } + + $dni = trim($request->input('dni')); + $email = trim($request->input('email')); + + if (empty($dni) || empty($email)) { + return back()->with('mensaje', 'Debes ingresar tu DNI y correo electrónico.'); + } + + $jugador = Jugador::where('documento', $dni)->where('email', $email)->first(); + $aficionado = Aficionado::where('dni', $dni)->where('email', $email)->first(); + + $usuario = $jugador ?: $aficionado; + + if (!$usuario) { + return back()->with('mensaje', 'No se encontró un usuario con ese DNI y correo.'); + } + + $token = bin2hex(random_bytes(16)); + $expires = now()->addHour(); + + if ($jugador) { + $jugador->update([ + 'reset_token' => $token, + 'reset_expira' => $expires + ]); + } else { + $aficionado->update([ + 'reset_token' => $token, + 'reset_expira' => $expires + ]); + } + try { + Mail::to($usuario->email)->send(new ResetPasswordMail($usuario, $token)); + } catch (\Exception $e) { + Log::error("Error enviando mail de recuperación: " . $e->getMessage()); + } + + return back()->with('mensaje', '📩 Te enviamos un correo con las instrucciones para recuperar tu contraseña.'); + } + + public function resetPasswordForm($token) + { + // Verificar que el token exista y no esté expirado + $jugador = Jugador::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + $aficionado = Aficionado::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + + if (!$jugador && !$aficionado) { + return redirect()->route('recuperar')->with('mensaje', '❌ El enlace es inválido o ya expiró. Solicitá uno nuevo.'); + } + + return view('auth.reset_password', compact('token')); + } + + public function resetPassword(Request $request) + { + $request->validate([ + 'token' => 'required|string', + 'password' => 'required|confirmed|min:6', + ]); + + $token = $request->input('token'); + + $jugador = Jugador::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + $aficionado = Aficionado::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + + $usuario = $jugador ?: $aficionado; + + if (!$usuario) { + return redirect()->route('recuperar')->with('mensaje', '❌ El enlace es inválido o ya expiró. Solicitá uno nuevo.'); + } + + $usuario->update([ + 'password' => bcrypt($request->input('password')), + 'reset_token' => null, + 'reset_expira' => null, + ]); + + return redirect('/')->with('login_success', '✅ Contraseña cambiada correctamente. Ya podés iniciar sesión.'); + } + + public function registroAficionado(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('registro_msg', '⚠️ Error de verificación de seguridad (Captcha).')->withInput(); + } + + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'dni' => 'required|string|unique:aficionados,dni', + 'email' => 'required|email|unique:aficionados,email', + 'fecha_nacimiento' => 'nullable|date', + 'telefono' => 'nullable|string|max:50', + 'localidad' => 'nullable|string|max:100', + 'password' => 'required|confirmed|min:6', + ]); + + $data['password'] = bcrypt($data['password']); + $data['fecha_registro'] = now(); + + $aficionado = Aficionado::create($data); + + try { + Mail::to($aficionado->email)->send(new WelcomeMail($aficionado, 'aficionado')); + } catch (\Exception $e) { + Log::error("Error enviando mail de bienvenida a Aficionado: " . $e->getMessage()); + } + + return redirect()->route('asociate')->with('mensaje', '✅ Te registraste correctamente. Ya podés iniciar sesión.'); + } + + public function buscarJugador(Request $request) + { + $request->validate([ + 'nombre' => 'required|string', + 'apellido' => 'required|string', + 'dni' => 'required|string', + 'acepto' => 'required', + ]); + + $dni = preg_replace('/[^0-9]/', '', $request->input('dni')); + $nombre = strtoupper(trim($request->input('nombre'))); + $apellido = strtoupper(trim($request->input('apellido'))); + + // Buscar jugador por DNI + $jugador = Jugador::where('documento', $dni)->first(); + + if (!$jugador) { + // Verificar si ya está registrado como aficionado + $aficionado = Aficionado::where('dni', $dni)->first(); + if ($aficionado) { + return redirect()->route('asociate')->with('mensaje', '⚠️ Ya estás registrado como aficionado.'); + } + // No existe - se debe registrar como aficionado + return redirect()->route('asociate')->with('mensaje', '⚠️ No encontramos tu registro como jugador. Podés registrarte como aficionado.'); + } + + // --- Lógica Smart Match --- + $nombreEnBD = $this->normalizeString($jugador->nombre ?? ''); + $apellidoEnBD = $this->normalizeString($jugador->apellido ?? ''); + $terminosBD = explode(' ', $nombreEnBD . ' ' . $apellidoEnBD); + + $palabrasNombreIn = explode(' ', $this->normalizeString($nombre)); + $palabrasApellidoIn = explode(' ', $this->normalizeString($apellido)); + + $nombreCoincide = false; + foreach ($palabrasNombreIn as $p) { + if ($this->isApproxMatch($p, $terminosBD)) { + $nombreCoincide = true; + break; + } + } + + $apellidoCoincide = false; + foreach ($palabrasApellidoIn as $p) { + if ($this->isApproxMatch($p, $terminosBD)) { + $apellidoCoincide = true; + break; + } + } + + if (!$nombreCoincide || !$apellidoCoincide) { + return redirect()->route('asociate')->with('mensaje', '⚠️ El DNI ingresado no coincide con el Nombre y Apellido proporcionados. Por favor, verifica tus datos.'); + } + // --- Fin Smart Match --- + + if ($jugador->activo) { + return redirect()->route('asociate')->with('registro_msg', 'Este jugador ya está registrado en el sistema.'); + } + + // Jugador encontrado e inactivo - mostrar formulario para completar + $club = null; + if ($jugador->id_club_actual) { + $clubObj = \App\Models\Club::find($jugador->id_club_actual); + $club = $clubObj ? $clubObj->nombre : null; + } + + $jugador_encontrado = [ + 'documento' => $jugador->documento, + 'nombre' => $jugador->nombre, + 'apellido' => $jugador->apellido, + 'fecha_nacimiento' => $jugador->fecha_nacimiento, + 'club' => $club, + 'categoria' => $jugador->categoria, + ]; + + return view('auth.asociate', compact('jugador_encontrado'))->with('tab', 'jugador'); + } + + public function completarRegistroJugador(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('registro_msg', '⚠️ Error de verificación de seguridad (Captcha).')->withInput(); + } + + $request->validate([ + 'dni' => 'required|string', + 'email' => 'required|email', + 'telefono' => 'nullable|string', + 'password' => 'required|confirmed|min:6', + ]); + + $dni = preg_replace('/[^0-9]/', '', $request->input('dni')); + + $jugador = Jugador::where('documento', $dni)->first(); + + if (!$jugador) { + return redirect()->route('asociate')->with('registro_msg', 'Jugador no encontrado.'); + } + + if ($jugador->activo) { + return redirect()->route('asociate')->with('registro_msg', 'Este jugador ya está registrado.'); + } + + $jugador->update([ + 'email' => $request->input('email'), + 'telefono' => $request->input('telefono'), + 'password' => bcrypt($request->input('password')), + 'activo' => 1, + 'fecha_registro' => now(), + ]); + + try { + Mail::to($jugador->email)->send(new WelcomeMail($jugador, 'jugador')); + } catch (\Exception $e) { + Log::error("Error enviando mail de bienvenida a Jugador: " . $e->getMessage()); + } + + return redirect()->route('asociate')->with('mensaje', '✅ Registro completado exitosamente. Ya podés iniciar sesión.'); + } + + /** + * Normaliza un string para comparaciones (mayúsculas, sin acentos, sin espacios extras) + */ + private function normalizeString($str) + { + $unwanted_array = ['Š'=>'S', 'š'=>'s', 'Ž'=>'Z', 'ž'=>'z', 'À'=>'A', 'Á'=>'A', 'Â'=>'A', 'Ã'=>'A', 'Ä'=>'A', 'Å'=>'A', 'Æ'=>'A', 'Ç'=>'C', 'È'=>'E', 'É'=>'E', + 'Ê'=>'E', 'Ë'=>'E', 'Ì'=>'I', 'Í'=>'I', 'Î'=>'I', 'Ï'=>'I', 'Ñ'=>'N', 'Ò'=>'O', 'Ó'=>'O', 'Ô'=>'O', 'Õ'=>'O', 'Ö'=>'O', 'Ø'=>'O', 'Ù'=>'U', + 'Ú'=>'U', 'Û'=>'U', 'Ü'=>'U', 'Ý'=>'Y', 'Þ'=>'B', 'ß'=>'Ss', 'à'=>'a', 'á'=>'a', 'â'=>'a', 'ã'=>'a', 'ä'=>'a', 'å'=>'a', 'æ'=>'a', 'ç'=>'c', + 'è'=>'e', 'é'=>'e', 'ê'=>'e', 'ë'=>'e', 'ì'=>'i', 'í'=>'i', 'î'=>'i', 'ï'=>'i', 'ð'=>'o', 'ñ'=>'n', 'ò'=>'o', 'ó'=>'o', 'ô'=>'o', 'õ'=>'o', + 'ö'=>'o', 'ø'=>'o', 'ù'=>'u', 'ú'=>'u', 'û'=>'u', 'ý'=>'y', 'þ'=>'b', 'ÿ'=>'y']; + + $str = strtr($str, $unwanted_array); + return strtoupper(trim(preg_replace('/\s+/', ' ', $str))); + } + + /** + * Comprueba si una palabra coincide aproximadamente con alguna de la lista + */ + private function isApproxMatch($word, $list) + { + if (strlen($word) < 3) return false; + + foreach ($list as $item) { + if (strlen($item) < 3) continue; + + // Coincidencia exacta o contenida + if ($word === $item || strpos($item, $word) !== false || strpos($word, $item) !== false) { + return true; + } + + // Levenshtein para errores de tipeo (máximo 1 de distancia) + if (levenshtein($word, $item) <= 1) { + return true; + } + } + return false; + } +} diff --git a/app/Http/Controllers/ClubController.php b/app/Http/Controllers/ClubController.php new file mode 100644 index 0000000..9ad2eb8 --- /dev/null +++ b/app/Http/Controllers/ClubController.php @@ -0,0 +1,47 @@ +get(); + return response()->json($clubes); + } + + public function show($id) + { + $club = Club::with('equipos', 'jugadores')->findOrFail($id); + return response()->json($club); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + ]); + $club = Club::create($data); + return response()->json($club, 201); + } + + public function update(Request $request, $id) + { + $club = Club::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'sometimes|string|max:100', + ]); + $club->update($data); + return response()->json($club); + } + + public function destroy($id) + { + $club = Club::findOrFail($id); + $club->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +getUserRole(); + + // Segmentar contenido + $filteredMd = $this->segmentMarkdown($md, $role); + + $parsedown = new \Parsedown(); + $content = $parsedown->text($filteredMd); + } + + return view('documentacion.index', compact('content')); + } + + /** + * Detecta el rol del usuario basado en las variables de sesión + */ + private function getUserRole() + { + if (session()->get('admin_logged_in')) { + $adminRole = session()->get('admin_role'); + return ($adminRole == 1) ? 'superadmin' : 'admin_club'; + } + + if (session()->get('user_logged_in')) { + return session()->get('user_tipo'); // 'jugador' o 'aficionado' + } + + return 'visitante'; + } + + /** + * Segmenta y filtra el Markdown según el rol + */ + private function segmentMarkdown($md, $role) + { + $markers = [ + 'cap1' => '', + 'cap2' => '', + 'cap3' => '', + 'cap4' => '', + 'cap5' => '', + 'faq' => '## ❓ Preguntas Frecuentes' + ]; + + // Encontrar posiciones de los marcadores + $positions = []; + foreach ($markers as $key => $marker) { + $pos = strpos($md, $marker); + if ($pos !== false) { + $positions[$key] = $pos; + } + } + asort($positions); + + $keys = array_keys($positions); + $sections = []; + + // Intro (antes del primer capítulo) + $sections['intro'] = substr($md, 0, $positions[$keys[0]]); + + // Capítulos y FAQ + for ($i = 0; $i < count($keys); $i++) { + $start = $positions[$keys[$i]]; + $end = isset($keys[$i+1]) ? $positions[$keys[$i+1]] : strlen($md); + $sections[$keys[$i]] = substr($md, $start, $end - $start); + } + + // Determinar qué capítulos mostrar + $allowedChapters = [1]; // Todos ven el Cap 1 + $showSections = ['intro', 'cap1', 'faq']; + + switch ($role) { + case 'superadmin': + $allowedChapters = [1, 2, 3, 4, 5]; + $showSections = ['intro', 'cap1', 'cap2', 'cap3', 'cap4', 'cap5', 'faq']; + break; + case 'admin_club': + $allowedChapters = [1, 4]; + $showSections = ['intro', 'cap1', 'cap4', 'faq']; + break; + case 'jugador': + $allowedChapters = [1, 2]; + $showSections = ['intro', 'cap1', 'cap2', 'faq']; + break; + case 'aficionado': + $allowedChapters = [1, 3]; + $showSections = ['intro', 'cap1', 'cap3', 'faq']; + break; + default: // visitante + $allowedChapters = [1]; + $showSections = ['intro', 'cap1', 'faq']; + break; + } + + // Filtrar Tabla de Contenidos en la Intro + $sections['intro'] = $this->filterTOC($sections['intro'], $allowedChapters); + + // Unir secciones seleccionadas + $finalMd = ""; + foreach ($showSections as $s) { + if (isset($sections[$s])) { + $finalMd .= $sections[$s] . "\n\n---\n\n"; + } + } + + return $finalMd; + } + + /** + * Filtra la tabla de contenidos para mostrar solo los capítulos permitidos + */ + private function filterTOC($intro, $allowedChapters) + { + $lines = explode("\n", $intro); + $filteredLines = []; + $inTable = false; + + foreach ($lines as $line) { + if (strpos($line, '| Capítulo | Perfil |') !== false) { + $inTable = true; + $filteredLines[] = $line; + continue; + } + + if ($inTable) { + if (trim($line) === '' || (strpos($line, '|') === false && trim($line) !== '')) { + $inTable = false; + $filteredLines[] = $line; + continue; + } + + if (strpos($line, '|---|') !== false) { + $filteredLines[] = $line; + continue; + } + + // Filtrar fila de la tabla + $matched = false; + foreach ($allowedChapters as $cap) { + if (strpos($line, "[Capítulo $cap]") !== false) { + $matched = true; + break; + } + } + + if ($matched) { + $filteredLines[] = $line; + } + } else { + $filteredLines[] = $line; + } + } + return implode("\n", $filteredLines); + } + + public function download() + { + $path = base_path('misc/MANUAL_USUARIO.md'); + + if (!File::exists($path)) { + abort(404, 'No se pudo generar el manual porque el archivo base no existe.'); + } + + $md = File::get($path); + + // Detectar rol de la sesión + $role = $this->getUserRole(); + + // Segmentar contenido + $filteredMd = $this->segmentMarkdown($md, $role); + + // Convertir Markdown a HTML + $parsedown = new \Parsedown(); + $htmlContent = $parsedown->text($filteredMd); + + // Preparar Logo en Base64 para el PDF + $logoBase64 = null; + $logoPath = public_path('logo.png'); + if (File::exists($logoPath)) { + $type = pathinfo($logoPath, PATHINFO_EXTENSION); + $data = File::get($logoPath); + $logoBase64 = 'data:image/' . $type . ';base64,' . base64_encode($data); + } + + // Depuración temporal: Descomenta si querés ver qué rol detecta antes de generar el PDF + // dd('Rol detectado: ' . $role); + + // Generar PDF con dompdf + $pdf = Pdf::loadView('documentacion.pdf', [ + 'content' => $htmlContent, + 'logo' => $logoBase64 + ]); + + // Ajustar papel y orientación + $pdf->setPaper('A4', 'portrait'); + + // Nombre de archivo único para evitar caché de navegador + $filename = 'Manual_Segmentado_' . $role . '_' . time() . '.pdf'; + + return $pdf->download($filename); + } +} diff --git a/app/Http/Controllers/EquipoController.php b/app/Http/Controllers/EquipoController.php new file mode 100644 index 0000000..acbb688 --- /dev/null +++ b/app/Http/Controllers/EquipoController.php @@ -0,0 +1,57 @@ +get(); + return response()->json($equipos); + } + + public function show($id) + { + $equipo = Equipo::with('club', 'jugadores')->findOrFail($id); + return response()->json($equipo); + } + + public function publicShow($id) + { + $equipo = Equipo::with(['club', 'jugadores'])->findOrFail($id); + return view('equipos.show', compact('equipo')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_club' => 'required|integer|exists:clubes,id_club', + 'categoria' => 'required|string|max:20', + 'division' => 'nullable|string|max:5', + ]); + $equipo = Equipo::create($data); + return response()->json($equipo, 201); + } + + public function update(Request $request, $id) + { + $equipo = Equipo::findOrFail($id); + $data = $request->validate([ + 'id_club' => 'sometimes|integer|exists:clubes,id_club', + 'categoria' => 'sometimes|string|max:20', + 'division' => 'nullable|string|max:5', + ]); + $equipo->update($data); + return response()->json($equipo); + } + + public function destroy($id) + { + $equipo = Equipo::findOrFail($id); + $equipo->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/EventoController.php b/app/Http/Controllers/EventoController.php new file mode 100644 index 0000000..826cd21 --- /dev/null +++ b/app/Http/Controllers/EventoController.php @@ -0,0 +1,84 @@ +get('fecha', now()->toDateString()); + $fechaSeleccionada = Carbon::parse($fechaStr); + + // Generar rango de fechas (Ayer, Hoy, Mañana + otros) + $fechasNav = []; + for ($i = -3; $i <= 3; $i++) { + $d = now()->addDays($i); + $fechasNav[] = [ + 'label' => $this->getFechaLabel($d), + 'fecha' => $d->toDateString(), + 'active' => $d->isSameDay($fechaSeleccionada) + ]; + } + + $eventos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->whereDate('fecha_evento', $fechaSeleccionada->toDateString()) + ->orderBy('hora_inicio', 'asc') + ->get() + ->map(function ($e) { + $e->estado = $this->calcularEstado($e->fecha_evento, $e->hora_inicio, $e->hora_fin); + return $e; + }); + + return view('eventos.index', compact('eventos', 'fechasNav', 'fechaSeleccionada')); + } + + private function getFechaLabel(Carbon $date) + { + $fecha = $date->format('d/m'); + if ($date->isToday()) return "HOY $fecha"; + if ($date->isYesterday()) return "AYER $fecha"; + if ($date->isTomorrow()) return "MAÑANA $fecha"; + + return $date->translatedFormat('D d/m'); + } + + public function show($id) + { + $evento = Evento::with(['equipoLocal.club', 'equipoVisitante.club', 'qrCodes']) + ->findOrFail($id); + + $evento->estado = $this->calcularEstado($evento->fecha_evento, $evento->hora_inicio, $evento->hora_fin); + + $isAdmin = session()->has('admin_logged_in') && session('admin_logged_in'); + $isUser = session()->has('user_logged_in') && session('user_logged_in'); + + return view('eventos.show', compact('evento', 'isAdmin', 'isUser')); + } + + private function calcularEstado($fechaEvento, $horaInicio, $horaFin) + { + $tz = new \DateTimeZone(config('app.timezone', 'America/Argentina/Buenos_Aires')); + + // Asegurarnos de tener strings limpios (Y-m-d y H:i:s) + $f = ($fechaEvento instanceof \Carbon\Carbon) ? $fechaEvento->format('Y-m-d') : substr($fechaEvento, 0, 10); + $h1 = ($horaInicio instanceof \Carbon\Carbon) ? $horaInicio->format('H:i:s') : $horaInicio; + $h2 = ($horaFin instanceof \Carbon\Carbon) ? $horaFin->format('H:i:s') : $horaFin; + + $inicio = new \DateTime("$f $h1", $tz); + $fin = new \DateTime("$f $h2", $tz); + + if ($fin <= $inicio) { + $fin->modify('+1 day'); + } + + $ahora = new \DateTime('now', $tz); + + if ($ahora >= $inicio && $ahora <= $fin) return 'Activo'; + if ($ahora < $inicio) return 'Próximo'; + return 'Finalizado'; + } +} diff --git a/app/Http/Controllers/GeniusAgentController.php b/app/Http/Controllers/GeniusAgentController.php new file mode 100644 index 0000000..7de4886 --- /dev/null +++ b/app/Http/Controllers/GeniusAgentController.php @@ -0,0 +1,81 @@ +validate([ + 'message' => ['required', 'string', 'max:1000'], + 'thread_id' => ['nullable', 'string', 'uuid'], + ]); + + $message = $request->string('message')->trim()->toString(); + + if (session('admin_logged_in')) { + return $this->handleAdmin($message, $request->input('thread_id')); + } + + return $this->handlePublic($request, $message); + } + + private function handleAdmin(string $message, ?string $threadId): JsonResponse + { + $adminId = (int) session('admin_id'); + $isSuperadmin = (int) session('admin_role') === 1; + $thread = AgentThread::findOrCreateForAdmin($threadId, $adminId); + + $reply = $this->service->chatAdmin($message, $thread, $isSuperadmin); + + return response()->json([ + 'reply' => $reply, + 'thread_id' => $thread->thread_id, + ]); + } + + private function handlePublic(Request $request, string $message): JsonResponse + { + $maxMessages = (int) config('services.genius.max_messages_per_session', 20); + $windowMin = (int) config('services.genius.session_window_minutes', 60); + + $session = $request->session(); + $startedAt = $session->get('agent_window_started_at'); + $count = (int) $session->get('agent_window_count', 0); + + if ($startedAt === null || (time() - (int) $startedAt) > $windowMin * 60) { + $startedAt = time(); + $count = 0; + } + + if ($maxMessages > 0 && $count >= $maxMessages) { + $remaining = max(1, (int) ceil(($windowMin * 60 - (time() - (int) $startedAt)) / 60)); + return response()->json([ + 'reply' => "Llegaste al límite de {$maxMessages} consultas por sesión. " + . "Volvé a intentar en {$remaining} minuto(s) o contactá directamente a OnAPB.", + 'limit_reached' => true, + ]); + } + + $history = $session->get('agent_messages', []); + $reply = $this->service->chatPublic($message, $history); + + $history[] = ['role' => 'user', 'content' => $message]; + $history[] = ['role' => 'assistant', 'content' => $reply]; + + $session->put('agent_messages', $history); + $session->put('agent_window_started_at', $startedAt); + $session->put('agent_window_count', $count + 1); + + return response()->json(['reply' => $reply]); + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php new file mode 100644 index 0000000..e32bb4c --- /dev/null +++ b/app/Http/Controllers/HomeController.php @@ -0,0 +1,86 @@ +getEventos(); + $promociones = Promocion::whereNotNull('descripcion') + ->where('descripcion', '!=', '') + ->orderBy('id', 'desc') + ->get(); + + $carouselItems = CarouselItem::where('activo', true) + ->orderBy('orden', 'asc') + ->get(); + + $noticias = Noticia::orderBy('id', 'desc') + ->take(3) + ->get(); + + $torneos = Torneo::orderBy('id', 'desc') + ->get(); + + $tournamentService = new TournamentService(); + $standingsData = []; + foreach ($torneos as $t) { + $standingsData[$t->id] = $tournamentService->getStandings($t->id); + } + + return view('welcome', compact('eventos', 'promociones', 'carouselItems', 'noticias', 'torneos', 'standingsData')); + } + + private function getEventos() + { + $eventos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->orderBy('fecha_evento', 'asc') + ->orderBy('hora_inicio', 'asc') + ->get() + ->map(function ($e) { + $estado = $this->calcularEstado($e->fecha_evento, $e->hora_inicio, $e->hora_fin); + if (in_array($estado, ['Activo', 'Próximo'])) { + $e->estado = $estado; + return $e; + } + return null; + }) + ->filter() + ->values(); + + return $eventos; + } + + private function calcularEstado($fechaEvento, $horaInicio, $horaFin) + { + $tz = new \DateTimeZone(config('app.timezone', 'America/Argentina/Buenos_Aires')); + + // Asegurarnos de tener strings limpios (Y-m-d y H:i:s) + $f = ($fechaEvento instanceof \Carbon\Carbon) ? $fechaEvento->format('Y-m-d') : substr($fechaEvento, 0, 10); + $h1 = ($horaInicio instanceof \Carbon\Carbon) ? $horaInicio->format('H:i:s') : $horaInicio; + $h2 = ($horaFin instanceof \Carbon\Carbon) ? $horaFin->format('H:i:s') : $horaFin; + + $inicio = new \DateTime("$f $h1", $tz); + $fin = new \DateTime("$f $h2", $tz); + + if ($fin <= $inicio) { + $fin->modify('+1 day'); + } + + $ahora = new \DateTime('now', $tz); + + if ($ahora >= $inicio && $ahora <= $fin) return 'Activo'; + if ($ahora < $inicio) return 'Próximo'; + return 'Finalizado'; + } +} diff --git a/app/Http/Controllers/JugadorController.php b/app/Http/Controllers/JugadorController.php new file mode 100644 index 0000000..1867573 --- /dev/null +++ b/app/Http/Controllers/JugadorController.php @@ -0,0 +1,76 @@ +get(); + return response()->json($jugadores); + } + + public function show($id) + { + $jugador = Jugador::with('clubActual', 'clubOrigen', 'equipos')->findOrFail($id); + return response()->json($jugador); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_jugador' => 'nullable|string|max:6', + 'documento' => 'required|string|max:20|unique:jugadores', + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'fecha_nacimiento' => 'nullable|date', + 'edad' => 'nullable|integer', + 'categoria' => 'nullable|string|max:20', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'nullable|integer|exists:clubes,id_club', + 'activo' => 'nullable|boolean', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $jugador = Jugador::create($data); + return response()->json($jugador, 201); + } + + public function update(Request $request, $id) + { + $jugador = Jugador::findOrFail($id); + $data = $request->validate([ + 'documento' => 'sometimes|string|max:20|unique:jugadores,documento,' . $id . ',id_jugador', + 'nombre' => 'sometimes|string|max:100', + 'apellido' => 'sometimes|string|max:100', + 'fecha_nacimiento' => 'nullable|date', + 'edad' => 'nullable|integer', + 'categoria' => 'nullable|string|max:20', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'nullable|integer|exists:clubes,id_club', + 'activo' => 'nullable|boolean', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $jugador->update($data); + return response()->json($jugador); + } + + public function destroy($id) + { + $jugador = Jugador::findOrFail($id); + $jugador->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/JugadorEquipoController.php b/app/Http/Controllers/JugadorEquipoController.php new file mode 100644 index 0000000..60e9d3a --- /dev/null +++ b/app/Http/Controllers/JugadorEquipoController.php @@ -0,0 +1,73 @@ +get(); + return response()->json($relaciones); + } + + public function show($idJugador, $idEquipo) + { + $relacion = JugadorEquipo::where('id_jugador', $idJugador) + ->where('id_equipo', $idEquipo) + ->with('jugador', 'equipo') + ->firstOrFail(); + return response()->json($relacion); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_jugador' => 'required|string|max:6|exists:jugadores,id_jugador', + 'id_equipo' => 'required|integer|exists:equipos,id_equipo', + 'fecha_alta' => 'nullable|date', + ]); + $relacion = JugadorEquipo::create($data); + return response()->json($relacion, 201); + } + + public function update(Request $request, $idJugador, $idEquipo) + { + $relacion = JugadorEquipo::where('id_jugador', $idJugador) + ->where('id_equipo', $idEquipo) + ->firstOrFail(); + + $data = $request->validate([ + 'fecha_alta' => 'nullable|date', + ]); + $relacion->update($data); + return response()->json($relacion); + } + + public function destroy($idJugador, $idEquipo) + { + $relacion = JugadorEquipo::where('id_jugador', $idJugador) + ->where('id_equipo', $idEquipo) + ->firstOrFail(); + $relacion->delete(); + return response()->json(null, 204); + } + + public function porJugador($idJugador) + { + $relaciones = JugadorEquipo::where('id_jugador', $idJugador) + ->with('equipo') + ->get(); + return response()->json($relaciones); + } + + public function porEquipo($idEquipo) + { + $relaciones = JugadorEquipo::where('id_equipo', $idEquipo) + ->with('jugador') + ->get(); + return response()->json($relaciones); + } +} diff --git a/app/Http/Controllers/NoticiaController.php b/app/Http/Controllers/NoticiaController.php new file mode 100644 index 0000000..d2e017c --- /dev/null +++ b/app/Http/Controllers/NoticiaController.php @@ -0,0 +1,61 @@ +get(); + + if ($request->expectsJson()) { + return response()->json($noticias); + } + + return view('noticias.index', compact('noticias')); + } + + public function show($id) + { + $noticia = Noticia::findOrFail($id); + + if (request()->expectsJson()) { + return response()->json($noticia); + } + + return view('noticias.show', compact('noticia')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'titulo' => 'required|string|max:200', + 'contenido' => 'required', + 'imagen' => 'nullable|string|max:200', + ]); + $noticia = Noticia::create($data); + return response()->json($noticia, 201); + } + + public function update(Request $request, $id) + { + $noticia = Noticia::findOrFail($id); + $data = $request->validate([ + 'titulo' => 'sometimes|string|max:200', + 'contenido' => 'sometimes', + 'imagen' => 'nullable|string|max:200', + ]); + $noticia->update($data); + return response()->json($noticia); + } + + public function destroy($id) + { + $noticia = Noticia::findOrFail($id); + $noticia->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/NotificacionController.php b/app/Http/Controllers/NotificacionController.php new file mode 100644 index 0000000..eae56a9 --- /dev/null +++ b/app/Http/Controllers/NotificacionController.php @@ -0,0 +1,143 @@ +service = $service; + } + + // ── Helper: usuario logueado ── + private function getUserSession(): ?array + { + if (!session()->has('user_logged_in')) return null; + return [ + 'tipo' => session('user_tipo'), + 'id' => session('user_id'), + ]; + } + + /** + * GET /notificaciones — Listado completo paginado + */ + public function index() + { + $u = $this->getUserSession(); + if (!$u) return redirect('/')->with('panel_error', 'Debés iniciar sesión.'); + + $notificaciones = $this->service->obtenerTodas($u['tipo'], $u['id']); + $totalNoLeidas = $this->service->contarNoLeidas($u['tipo'], $u['id']); + + return view('notificaciones.index', compact('notificaciones', 'totalNoLeidas')); + } + + /** + * POST /notificaciones/{id}/leer — Marcar una como leída + */ + public function marcarLeida(int $id) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $this->service->marcarLeida($id, $u['tipo'], $u['id']); + return response()->json(['ok' => true]); + } + + /** + * POST /notificaciones/leer-todas — Marcar todas como leídas + */ + public function marcarTodasLeidas() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $count = $this->service->marcarTodasLeidas($u['tipo'], $u['id']); + return response()->json(['ok' => true, 'marcadas' => $count]); + } + + /** + * GET /notificaciones/count — Badge AJAX (devuelve JSON {count: N}) + */ + public function count() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['count' => 0]); + + return response()->json(['count' => $this->service->contarNoLeidas($u['tipo'], $u['id'])]); + } + + /** + * GET /notificaciones/latest — ID de la última no leída + */ + public function latest() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['id' => 0]); + + $notif = $this->service->obtenerNoLeidas($u['tipo'], $u['id'])->first(); + return response()->json([ + 'id' => $notif ? $notif->id : 0, + 'titulo' => $notif ? $notif->titulo : '', + 'mensaje' => $notif ? $notif->mensaje : '' + ]); + } + + /** + * DELETE /notificaciones/{id} — Eliminar una notificación + */ + public function eliminar(int $id) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $this->service->eliminar($id, $u['tipo'], $u['id']); + return response()->json(['ok' => true]); + } + + /** + * DELETE /notificaciones — Eliminar todas + */ + public function eliminarTodas() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $this->service->eliminarTodas($u['tipo'], $u['id']); + return response()->json(['ok' => true]); + } + /** + * POST /notificaciones/subscribe — Guardar suscripción para Web Push + */ + public function subscribe(Request $request) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false, 'error' => 'No session'], 401); + + $request->validate([ + 'endpoint' => 'required', + 'keys.p256dh' => 'required', + 'keys.auth' => 'required', + ]); + + \App\Models\PushSubscription::updateOrCreate( + [ + 'id_usuario' => (string) $u['id'], + 'tipo_usuario' => $u['tipo'], + 'endpoint' => $request->endpoint, + ], + [ + 'p256dh' => $request->keys['p256dh'], + 'auth' => $request->keys['auth'], + ] + ); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/PanelController.php b/app/Http/Controllers/PanelController.php new file mode 100644 index 0000000..c92dd3a --- /dev/null +++ b/app/Http/Controllers/PanelController.php @@ -0,0 +1,363 @@ +notifService = $notifService; + } + + // ── Helper: obtener usuario logueado ── + private function getUser() + { + if (!session()->has('user_logged_in')) { + return null; + } + + $tipo = session('user_tipo'); + $id = session('user_id'); + + if ($tipo === 'jugador') { + return ['user' => Jugador::find($id), 'tipo' => $tipo]; + } + return ['user' => Aficionado::find($id), 'tipo' => $tipo]; + } + + // ══════════════════════════════════ + // PANEL PRINCIPAL + // ══════════════════════════════════ + public function index(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) { + session()->flush(); + return redirect('/?logout_msg=' . urlencode('Tu usuario no fue encontrado. Iniciá sesión nuevamente.')); + } + + $user = $data['user']; + $userTipo = $data['tipo']; + $userId = session('user_id'); + + // Cargar relaciones de club/equipos para jugadores + if ($userTipo === 'jugador') { + $user->load(['clubActual', 'equipos.club']); + } + + // Obtener QRs del usuario (eventos que NO estén borrados) + if ($userTipo === 'jugador') { + $qrCodes = QrCode::whereHas('evento') + ->where('id_jugador', $user->id_jugador) + ->orderBy('creado', 'desc') + ->get(); + } else { + $qrCodes = QrCode::whereHas('evento') + ->where('id_aficionado', $user->id_aficionado) + ->orderBy('creado', 'desc') + ->get(); + } + + // Obtener beneficios de promociones + $promoQrs = PromoQr::with('promocion') + ->where('id_usuario', $userId) + ->where('tipo_usuario', $userTipo) + ->orderBy('generado_en', 'desc') + ->get(); + + return view('panel.index', compact('user', 'userTipo', 'qrCodes', 'promoQrs')); + } + + // ══════════════════════════════════ + // ACTUALIZAR DATOS + // ══════════════════════════════════ + public function actualizarDatos(Request $request) + { + $data = $this->getUser(); + if (!$data) return redirect('/'); + + $request->validate([ + 'email' => 'required|email', + 'telefono' => 'required|string', + 'localidad' => 'nullable|string', + ]); + + $user = $data['user']; + + if ($data['tipo'] === 'jugador') { + $user->update([ + 'email' => $request->input('email'), + 'telefono' => $request->input('telefono'), + ]); + } else { + $user->update([ + 'email' => $request->input('email'), + 'telefono' => $request->input('telefono'), + 'localidad' => $request->input('localidad'), + ]); + } + + return back()->with('panel_msg', 'Datos actualizados correctamente.'); + } + + // ══════════════════════════════════ + // CAMBIAR CONTRASEÑA + // ══════════════════════════════════ + public function cambiarPassword(Request $request) + { + $data = $this->getUser(); + if (!$data) return redirect('/'); + + $request->validate([ + 'password_actual' => 'required|string', + 'password_nueva' => 'required|confirmed|min:6', + ]); + + $user = $data['user']; + + if ($user->password) { + if (!Hash::check($request->input('password_actual'), $user->password)) { + return back()->with('panel_error', 'La contraseña actual es incorrecta.'); + } + } + + $user->update([ + 'password' => bcrypt($request->input('password_nueva')), + ]); + + return back()->with('panel_msg', 'Contraseña cambiada correctamente.'); + } + + // ══════════════════════════════════ + // SOLICITAR QR PARA EVENTO + // ══════════════════════════════════ + public function solicitarQr(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + + $id_evento = $request->input('id_evento'); + $evento = Evento::with(['equipoLocal', 'equipoVisitante'])->find($id_evento); + + if (!$evento) { + return back()->with('panel_error', 'Evento no encontrado.'); + } + + $qrs_a_generar = 0; + + if ($userTipo === 'jugador') { + // Verificar si ya generó QRs para este evento + $yaGenero = QrCode::where('id_evento', $id_evento) + ->where('id_jugador', $user->id_jugador) + ->count(); + + if ($yaGenero > 0) { + return back()->with('panel_error', 'Ya solicitaste QRs para este evento.'); + } + + // Verificar si el jugador está activo + if (!$user->activo) { + return back()->with('panel_error', 'Tu registro no está activo. Completá tu registro primero.'); + } + + // Verificar si pertenece a alguno de los equipos del evento + $pertenece = DB::table('jugador_equipo') + ->where('id_jugador', $user->id_jugador) + ->whereIn('id_equipo', array_filter([$evento->id_equipo_local, $evento->id_equipo_visitante])) + ->exists(); + + if ($pertenece) { + $qrs_a_generar = $evento->limite_qr_jugador ?? 3; // Límite configurable + $tipoQr = 'invitado'; + } else { + // Check if category is "libre" + $edadCategoria = date('Y') - \Carbon\Carbon::parse($user->fecha_nacimiento)->format('Y'); + $categoriaDB = \App\Models\Categoria::where('edad_min', '<=', $edadCategoria) + ->where('edad_max', '>=', $edadCategoria) + ->first(); + + if ($categoriaDB && $categoriaDB->es_libre) { + $qrs_a_generar = 1; + $tipoQr = 'libre_50'; // Identificador para 50% de descuento + } else { + return back()->with('panel_error', 'No podés generar QR para este partido ya que no pertenecés a los equipos ni sos categoría Libre. Deberás abonar la totalidad de la entrada en puerta.'); + } + } + + if ($qrs_a_generar === 0) { + return back()->with('panel_error', 'No se permiten QRs para este evento.'); + } + + // Generar QRs + for ($i = 0; $i < $qrs_a_generar; $i++) { + QrCode::create([ + 'id_qr' => uniqid('qr_'), + 'id_evento' => $id_evento, + 'id_jugador' => $user->id_jugador, + 'tipo_qr' => $tipoQr ?? 'invitado', + 'escaneos_restantes' => 1, + 'creado' => now(), + ]); + } + + } else { + // Aficionado: no puede solicitar QRs para eventos + return back()->with('panel_error', 'Los aficionados no pueden solicitar QRs para eventos. Adquirí tu entrada directamente en el lugar.'); + } + + // Enviar mail con QRs + try { + Mail::to($user->email)->send(new QrCodeMail($user, $evento, $qrs_a_generar)); + } catch (\Exception $e) { + Log::error("Error enviando mail de QRs: " . $e->getMessage()); + } + + // Notificación interna + $this->notifService->enviar( + $userTipo, + $userTipo === 'jugador' ? $user->id_jugador : $user->id_aficionado, + 'sistema', + '🎫 QRs Generados', + "Tus {$qrs_a_generar} QR(s) para el evento " . ($evento->nombre_evento ?? 'seleccionado') . " ya están disponibles.", + route('panel.mis.qrs', ['evento' => $id_evento]) + ); + + return redirect()->route('panel.mis.qrs', ['evento' => $id_evento]) + ->with('panel_msg', "¡QR(s) generados correctamente! ({$qrs_a_generar})"); + } + + // ══════════════════════════════════ + // MIS QRS (per evento) + // ══════════════════════════════════ + public function misQrs(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + $id_evento = $request->query('evento'); + + // Traer QRs del usuario (solo de eventos activos) + $query = QrCode::whereHas('evento') + ->with(['evento.equipoLocal.club', 'evento.equipoVisitante.club', 'evento.torneo']); + + if ($userTipo === 'jugador') { + $query->where('id_jugador', $user->id_jugador); + $user->load('clubActual'); + } else { + $query->where('id_aficionado', $user->id_aficionado); + } + + if ($id_evento) { + $query->where('id_evento', $id_evento); + $evento = Evento::find($id_evento); + } else { + $evento = null; + } + + $qrs = $query->orderBy('creado', 'desc')->get(); + + // Adjuntar información de grupo si hay torneo + foreach ($qrs as $qr) { + if ($qr->evento && $qr->evento->id_torneo && $qr->evento->id_equipo_local) { + $rel = DB::table('torneo_equipo') + ->where('id_torneo', $qr->evento->id_torneo) + ->where('id_equipo', $qr->evento->id_equipo_local) + ->first(); + if ($rel) { + $qr->evento->grupo_nombre = $rel->grupo ?? 'General'; + } + } + } + + return view('panel.mis_qrs', compact('user', 'userTipo', 'qrs', 'evento')); + } + + // ══════════════════════════════════ + // GENERAR QR PARA PROMOCIÓN + // ══════════════════════════════════ + public function generarPromoQr(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + $userId = session('user_id'); + + $id_promo = $request->input('id_promo'); + $promo = Promocion::find($id_promo); + + if (!$promo) { + return back()->with('panel_error', 'Promoción no encontrada.'); + } + + // Verificar si ya generó QR para esta promo + $yaGenero = PromoQr::where('id_promo', $id_promo) + ->where('id_usuario', $userId) + ->where('tipo_usuario', $userTipo) + ->count(); + + if ($yaGenero > 0) { + return back()->with('panel_error', 'Ya generaste un QR para esta promoción.'); + } + + $id_qr = bin2hex(random_bytes(8)); + + PromoQr::create([ + 'id_qr' => $id_qr, + 'id_promo' => $id_promo, + 'id_usuario' => $userId, + 'tipo_usuario' => $userTipo, + 'generado_en' => now(), + 'usado' => false, + ]); + + return redirect()->route('panel.promo.qr.ver', ['id' => $id_qr]) + ->with('panel_msg', '¡QR de beneficio generado correctamente!'); + } + + // ══════════════════════════════════ + // VER QR DE PROMOCIÓN + // ══════════════════════════════════ + public function verPromoQr($id) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + + if ($userTipo === 'jugador') { + $user->load('clubActual'); + } + + $promoQr = PromoQr::with('promocion') + ->where('id_qr', $id) + ->where('id_usuario', session('user_id')) + ->where('tipo_usuario', $userTipo) + ->firstOrFail(); + + return view('panel.promo_qr', compact('promoQr', 'user', 'userTipo')); + } +} diff --git a/app/Http/Controllers/PromoQrController.php b/app/Http/Controllers/PromoQrController.php new file mode 100644 index 0000000..4f424c7 --- /dev/null +++ b/app/Http/Controllers/PromoQrController.php @@ -0,0 +1,52 @@ +get(); + return response()->json($promoQrs); + } + + public function show($id) + { + $promoQr = PromoQr::with('promocion', 'usuario')->findOrFail($id); + return response()->json($promoQr); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_promo' => 'required|integer|exists:promociones,id', + 'id_usuario' => 'required|integer', + 'tipo_usuario' => 'required|in:jugador,aficionado', + ]); + $data['id_qr'] = Str::uuid()->toString(); + $promoQr = PromoQr::create($data); + return response()->json($promoQr, 201); + } + + public function update(Request $request, $id) + { + $promoQr = PromoQr::findOrFail($id); + $data = $request->validate([ + 'usado' => 'nullable|boolean', + 'usado_en' => 'nullable', + ]); + $promoQr->update($data); + return response()->json($promoQr); + } + + public function destroy($id) + { + $promoQr = PromoQr::findOrFail($id); + $promoQr->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/PromocionController.php b/app/Http/Controllers/PromocionController.php new file mode 100644 index 0000000..9d97dfe --- /dev/null +++ b/app/Http/Controllers/PromocionController.php @@ -0,0 +1,70 @@ +expectsJson()) { + return response()->json($promociones); + } + + $categorias = $promociones->pluck('categoria')->filter()->unique()->values(); + + return view('promos.index', compact('promociones', 'categorias')); + } + + public function show($id) + { + $promocion = Promocion::with('promoQrs')->findOrFail($id); + return response()->json($promocion); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'direccion' => 'required|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen' => 'nullable|string|max:200', + ]); + $promocion = Promocion::create($data); + return response()->json($promocion, 201); + } + + public function update(Request $request, $id) + { + $promocion = Promocion::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'sometimes|string|max:100', + 'direccion' => 'sometimes|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen' => 'nullable|string|max:200', + ]); + $promocion->update($data); + return response()->json($promocion); + } + + public function destroy($id) + { + $promocion = Promocion::findOrFail($id); + $promocion->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/QrCodeController.php b/app/Http/Controllers/QrCodeController.php new file mode 100644 index 0000000..559b56b --- /dev/null +++ b/app/Http/Controllers/QrCodeController.php @@ -0,0 +1,57 @@ +get(); + return response()->json($qrCodes); + } + + public function show($id) + { + $qrCode = QrCode::with('evento', 'jugador', 'aficionado')->findOrFail($id); + return response()->json($qrCode); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_evento' => 'required|string|max:36', + 'id_jugador' => 'nullable|string|max:6', + 'tipo_qr' => 'required|in:invitado,publico', + 'escaneos_restantes' => 'nullable|integer|min:1', + 'id_aficionado' => 'nullable|integer', + ]); + $data['id_qr'] = Str::uuid()->toString(); + $qrCode = QrCode::create($data); + return response()->json($qrCode, 201); + } + + public function update(Request $request, $id) + { + $qrCode = QrCode::findOrFail($id); + $data = $request->validate([ + 'id_evento' => 'sometimes|string|max:36', + 'id_jugador' => 'nullable|string|max:6', + 'tipo_qr' => 'sometimes|in:invitado,publico', + 'escaneos_restantes' => 'nullable|integer|min:1', + 'id_aficionado' => 'nullable|integer', + ]); + $qrCode->update($data); + return response()->json($qrCode); + } + + public function destroy($id) + { + $qrCode = QrCode::findOrFail($id); + $qrCode->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/QrDownloadController.php b/app/Http/Controllers/QrDownloadController.php new file mode 100644 index 0000000..c606bd7 --- /dev/null +++ b/app/Http/Controllers/QrDownloadController.php @@ -0,0 +1,129 @@ +where('id_qr', $id) + ->firstOrFail(); + + // Verificar que el usuario tenga permiso para descargar este QR + $isUser = session()->has('user_logged_in') && session('user_logged_in'); + $isAdmin = session()->has('admin_logged_in') && session('admin_logged_in'); + + if ($isUser) { + $userId = session('user_id'); + $userTipo = session('user_tipo'); + if ($userTipo === 'jugador' && $qr->id_jugador != $userId) abort(403); + if ($userTipo === 'aficionado' && $qr->id_aficionado != $userId) abort(403); + } elseif (!$isAdmin) { + abort(403); + } + + $user = $qr->jugador ?: $qr->aficionado; + $userTipo = $qr->id_jugador ? 'jugador' : 'aficionado'; + + // Convertir imágenes a Base64 para DomPDF (más fiable que URLs remotas/locales) + $qrImageBase64 = ''; + try { + $qrUrl = "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=" . urlencode($qr->id_qr); + $qrContent = @file_get_contents($qrUrl); + if ($qrContent) { + $qrImageBase64 = 'data:image/png;base64,' . base64_encode($qrContent); + } + } catch (\Exception $e) { } + + $club = ($qr->evento && $qr->evento->equipoLocal && $qr->evento->equipoLocal->club) ? $qr->evento->equipoLocal->club : null; + + $backgroundBase64 = null; + if ($club && $club->qr_background) { + // 1. Intentar en storage/app/public (ruta interna Laravel) + $pathInStorage = str_replace(['storage/', 'storage\\'], '', $club->qr_background); + $bgPath = storage_path('app/public/' . $pathInStorage); + + // 2. Intentar en public_path (desarrollo local o symlink funcional) + if (!file_exists($bgPath)) { + $bgPath = public_path($club->qr_background); + } + + // 3. Intentar estructura Hostinger (public_html es hermano de la carpeta laravel) + if (!file_exists($bgPath)) { + $bgPath = base_path('../public_html/' . $club->qr_background); + } + + if (file_exists($bgPath)) { + $bgContent = @file_get_contents($bgPath); + if ($bgContent) { + $ext = pathinfo($bgPath, PATHINFO_EXTENSION); + $backgroundBase64 = 'data:image/' . $ext . ';base64,' . base64_encode($bgContent); + } + } + } + + $logoBase64 = null; + $logoPathFinal = null; + + // Logo del club del jugador (no del local) si corresponde: + // jugador → su club actual; aficionado/sin club → club local; fallback → logo OnAPB. + $logoClub = null; + if ($userTipo === 'jugador' && $qr->jugador && $qr->jugador->clubActual) { + $logoClub = $qr->jugador->clubActual; + } elseif ($club) { + $logoClub = $club; + } + + if ($logoClub && $logoClub->imagen) { + $logoInStorage = str_replace(['storage/', 'storage\\'], '', $logoClub->imagen); + $lPath = storage_path('app/public/' . $logoInStorage); + + if (!file_exists($lPath)) { + $lPath = public_path($logoClub->imagen); + } + + if (!file_exists($lPath)) { + $lPath = base_path('../public_html/' . $logoClub->imagen); + } + + if (file_exists($lPath)) { + $logoPathFinal = $lPath; + } + } + + // Si no hay logo, usar logo general + if (!$logoPathFinal) { + if (file_exists(public_path('logo.png'))) { + $logoPathFinal = public_path('logo.png'); + } + } + + if ($logoPathFinal) { + $logoContent = @file_get_contents($logoPathFinal); + if ($logoContent) { + $ext = pathinfo($logoPathFinal, PATHINFO_EXTENSION); + $logoBase64 = 'data:image/' . $ext . ';base64,' . base64_encode($logoContent); + } + } + + $data = [ + 'qr' => $qr, + 'user' => $user, + 'userTipo' => $userTipo, + 'club' => $club, + 'qrImageBase64' => $qrImageBase64, + 'backgroundBase64' => $backgroundBase64, + 'logoBase64' => $logoBase64, + ]; + + $pdf = Pdf::loadView('pdf.qr_ticket', $data) + ->setPaper([0, 0, 320, 500], 'portrait'); + + return $pdf->download("onapb_qr_{$qr->id_qr}.pdf"); + } +} diff --git a/app/Http/Controllers/SeguimientoController.php b/app/Http/Controllers/SeguimientoController.php new file mode 100644 index 0000000..7d7fb98 --- /dev/null +++ b/app/Http/Controllers/SeguimientoController.php @@ -0,0 +1,122 @@ +notifService = $notifService; + } + + private function getUserSession(): ?array + { + if (!session()->has('user_logged_in')) return null; + return ['tipo' => session('user_tipo'), 'id' => session('user_id')]; + } + + /** + * POST /seguimiento/equipo/{id} — Toggle: seguir o dejar de seguir un equipo + */ + public function toggle(int $id) + { + $u = $this->getUserSession(); + if (!$u) { + return response()->json(['ok' => false, 'msg' => 'Debés iniciar sesión para seguir equipos.'], 401); + } + + $equipo = Equipo::find($id); + if (!$equipo) { + return response()->json(['ok' => false, 'msg' => 'Equipo no encontrado.'], 404); + } + + $existing = EquipoSeguimiento::where('id_equipo', $id) + ->where('tipo_usuario', $u['tipo']) + ->where('id_usuario', (string)$u['id']) + ->first(); + + if ($existing) { + $existing->delete(); + return response()->json(['ok' => true, 'siguiendo' => false, 'msg' => 'Dejaste de seguir este equipo.']); + } + + EquipoSeguimiento::create([ + 'id_equipo' => $id, + 'tipo_usuario'=> $u['tipo'], + 'id_usuario' => (string)$u['id'], + 'created_at' => now(), + ]); + + return response()->json(['ok' => true, 'siguiendo' => true, 'msg' => '¡Ahora seguís este equipo!']); + } + + /** + * GET /seguimiento/mis-equipos — Devuelve equipos seguidos y próximos partidos + */ + public function misEquipos() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['equipos' => []]); + + $seguimientos = EquipoSeguimiento::with(['equipo.club']) + ->where('tipo_usuario', $u['tipo']) + ->where('id_usuario', (string)$u['id']) + ->get(); + + $equiposIds = $seguimientos->pluck('id_equipo'); + + // Próximos partidos de los equipos seguidos + $proximosPartidos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->where('fecha_evento', '>=', now()->toDateString()) + ->where(function ($q) use ($equiposIds) { + $q->whereIn('id_equipo_local', $equiposIds) + ->orWhereIn('id_equipo_visitante', $equiposIds); + }) + ->orderBy('fecha_evento') + ->orderBy('hora_inicio') + ->limit(10) + ->get(); + + return response()->json([ + 'equipos' => $seguimientos->map(fn($s) => [ + 'id' => $s->id_equipo, + 'nombre' => ($s->equipo->club->nombre ?? 'Equipo') . ' ' . $s->equipo->categoria . ($s->equipo->division ? ' ' . $s->equipo->division : ''), + 'club' => $s->equipo->club->nombre ?? '—', + 'categoria'=> $s->equipo->categoria, + 'division' => $s->equipo->division, + ]), + 'proximos_partidos' => $proximosPartidos->map(fn($e) => [ + 'id' => $e->id_evento, + 'fecha' => $e->fecha_evento->format('d/m/Y'), + 'hora' => $e->hora_inicio ? $e->hora_inicio->format('H:i') : '', + 'local' => $e->equipoLocal->club->nombre ?? '?', + 'visitante'=> $e->equipoVisitante->club->nombre ?? '?', + 'sede' => $e->sede, + ]), + ]); + } + + /** + * GET /seguimiento/estado/{id_equipo} — ¿El usuario sigue este equipo? + */ + public function estado(int $id) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['siguiendo' => false]); + + $siguiendo = EquipoSeguimiento::where('id_equipo', $id) + ->where('tipo_usuario', $u['tipo']) + ->where('id_usuario', (string)$u['id']) + ->exists(); + + return response()->json(['siguiendo' => $siguiendo]); + } +} diff --git a/app/Http/Controllers/TorneoController.php b/app/Http/Controllers/TorneoController.php new file mode 100644 index 0000000..d96bd0d --- /dev/null +++ b/app/Http/Controllers/TorneoController.php @@ -0,0 +1,85 @@ +query('grupo'); + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + $tournamentService = new \App\Services\TournamentService(); + $stats = $tournamentService->getStandings($id, true); + + $grupos = array_keys($stats); + + if ($selectedGroup && isset($stats[$selectedGroup])) { + $stats = [$selectedGroup => $stats[$selectedGroup]]; + } + + $followedTeamIds = []; + if (session('user_logged_in') && session('user_id')) { + $followedTeamIds = \App\Models\EquipoSeguimiento::where('id_usuario', session('user_id')) + ->where('tipo_usuario', session('user_tipo')) + ->pluck('id_equipo') + ->toArray(); + } + + return view('torneos.standings', compact('torneo', 'stats', 'grupos', 'selectedGroup', 'followedTeamIds')); + } + + public function topScorers(Request $request, $id) + { + $torneo = Torneo::findOrFail($id); + $selectedGroup = $request->query('grupo'); + + $query = \App\Models\EventoJugador::with(['jugador.clubActual']) + ->whereHas('evento', function($q) use ($id) { + $q->where('id_torneo', $id)->whereNotNull('marcador_local'); + }); + + if ($selectedGroup) { + $query->whereHas('evento.equipoLocal.torneos', function($q) use ($id, $selectedGroup) { + $q->where('torneos.id', $id)->where('torneo_equipo.grupo', $selectedGroup); + }); + } + + $scorers = $query->select('id_jugador', DB::raw('SUM(puntos) as total_puntos'), DB::raw('COUNT(id_evento) as partidos_jugados')) + ->groupBy('id_jugador') + ->orderByDesc('total_puntos') + ->take(20)->get(); + + $grupos = DB::table('torneo_equipo')->where('id_torneo', $id)->distinct()->pluck('grupo')->filter(); + + return view('torneos.scorers', compact('torneo', 'scorers', 'grupos', 'selectedGroup')); + } + + public function playoffs(Request $request, $id) + { + $selectedGroup = $request->query('grupo'); + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + // Get groups from pivot + $grupos = DB::table('torneo_equipo')->where('id_torneo', $id)->distinct()->pluck('grupo')->filter(); + + $ts = new \App\Services\TournamentService(); + $bracket = $ts->getPlayoffBrackets($id); + + // Map to simpler keys if needed by view + $bracket = [ + 'cuartos' => collect($bracket[\App\Models\Evento::FASE_CUARTOS] ?? []), + 'semis' => collect($bracket[\App\Models\Evento::FASE_SEMIS] ?? []), + 'final' => collect($bracket[\App\Models\Evento::FASE_FINAL] ?? []), + ]; + + return view('torneos.playoffs', compact('torneo', 'bracket', 'grupos', 'selectedGroup')); + } +} diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..440135b --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,40 @@ +headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-XSS-Protection', '1; mode=block'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set( + 'Permissions-Policy', + 'camera=(), microphone=(), geolocation=()' + ); + $response->headers->set( + 'Strict-Transport-Security', + 'max-age=31536000; includeSubDomains; preload' + ); + $response->headers->set( + 'Content-Security-Policy', + "default-src 'self'; " . + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; " . + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " . + "font-src 'self' data: https://fonts.gstatic.com; " . + "img-src 'self' data: https:; " . + "connect-src 'self' https://challenges.cloudflare.com; " . + "frame-src 'self' https://challenges.cloudflare.com; " . + "frame-ancestors 'self';" + ); + + return $response; + } +} diff --git a/app/Mail/QrCodeMail.php b/app/Mail/QrCodeMail.php new file mode 100644 index 0000000..d10222a --- /dev/null +++ b/app/Mail/QrCodeMail.php @@ -0,0 +1,29 @@ +user = $user; + $this->evento = $evento; + $this->cantidad = $cantidad; + } + + public function build() + { + return $this->subject('Tus QRs para el evento: ' . $this->evento->nombre_evento) + ->view('emails.qrcodes'); + } +} diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php new file mode 100644 index 0000000..4ca8203 --- /dev/null +++ b/app/Mail/ResetPasswordMail.php @@ -0,0 +1,27 @@ +user = $user; + $this->token = $token; + } + + public function build() + { + return $this->subject('Recuperar contraseña - OnAPB') + ->view('emails.reset_password'); + } +} diff --git a/app/Mail/WelcomeMail.php b/app/Mail/WelcomeMail.php new file mode 100644 index 0000000..f328ca4 --- /dev/null +++ b/app/Mail/WelcomeMail.php @@ -0,0 +1,27 @@ +user = $user; + $this->tipo = $tipo; + } + + public function build() + { + return $this->subject('¡Bienvenido a OnAPB!') + ->view('emails.welcome'); + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php new file mode 100644 index 0000000..0113782 --- /dev/null +++ b/app/Models/AdminUser.php @@ -0,0 +1,36 @@ +belongsTo(Club::class, 'id_club', 'id_club'); + } + + protected $hidden = [ + 'password', + 'reset_token', + ]; + + protected $casts = [ + 'role' => 'integer', + 'reset_expira' => 'datetime', + ]; +} diff --git a/app/Models/Aficionado.php b/app/Models/Aficionado.php new file mode 100644 index 0000000..003a298 --- /dev/null +++ b/app/Models/Aficionado.php @@ -0,0 +1,38 @@ + 'integer', + 'fecha_nacimiento' => 'date', + 'fecha_registro' => 'datetime', + 'reset_expira' => 'datetime', + ]; +} diff --git a/app/Models/AgentThread.php b/app/Models/AgentThread.php new file mode 100644 index 0000000..16b55cc --- /dev/null +++ b/app/Models/AgentThread.php @@ -0,0 +1,42 @@ + 'array', + 'expires_at' => 'datetime', + ]; + + public static function findOrCreateForAdmin(?string $threadId, int $adminId): static + { + if ($threadId) { + $thread = static::where('thread_id', $threadId) + ->where('admin_id', $adminId) + ->first(); + if ($thread) { + return $thread; + } + } + + return static::create([ + 'thread_id' => (string) Str::uuid(), + 'admin_id' => $adminId, + 'messages' => [], + 'expires_at' => now()->addDays(30), + ]); + } +} diff --git a/app/Models/CarouselItem.php b/app/Models/CarouselItem.php new file mode 100644 index 0000000..b2a2f7e --- /dev/null +++ b/app/Models/CarouselItem.php @@ -0,0 +1,25 @@ + 'boolean', + 'orden' => 'integer', + ]; +} diff --git a/app/Models/Categoria.php b/app/Models/Categoria.php new file mode 100644 index 0000000..9d9a672 --- /dev/null +++ b/app/Models/Categoria.php @@ -0,0 +1,19 @@ +id_club)) { + $model->id_club = (int) self::withTrashed()->max('id_club') + 1; + } + }); + } + + protected $casts = [ + 'id_club' => 'integer', + 'es_seleccion' => 'boolean', + ]; + + public function equipos() + { + return $this->hasMany(Equipo::class, 'id_club', 'id_club'); + } + + public function jugadores() + { + return $this->hasMany(Jugador::class, 'id_club_actual', 'id_club'); + } +} diff --git a/app/Models/Configuracion.php b/app/Models/Configuracion.php new file mode 100644 index 0000000..5c77e24 --- /dev/null +++ b/app/Models/Configuracion.php @@ -0,0 +1,45 @@ +first(); + return $config ? $config->valor : $default; + } + + /** + * Establecer o actualizar un valor de configuración. + * + * @param string $clave + * @param mixed $valor + * @param string|null $descripcion + * @return self + */ + public static function set($clave, $valor, $descripcion = null) + { + return self::updateOrCreate( + ['clave' => $clave], + ['valor' => $valor, 'descripcion' => $descripcion] + ); + } +} diff --git a/app/Models/Equipo.php b/app/Models/Equipo.php new file mode 100644 index 0000000..bb0d9c2 --- /dev/null +++ b/app/Models/Equipo.php @@ -0,0 +1,41 @@ + 'integer', + 'id_club' => 'integer', + ]; + + public function club() + { + return $this->belongsTo(Club::class, 'id_club', 'id_club'); + } + + public function jugadores() + { + return $this->belongsToMany(Jugador::class, 'jugador_equipo', 'id_equipo', 'id_jugador') + ->withPivot('fecha_alta'); + } + + public function torneos() + { + return $this->belongsToMany(Torneo::class, 'torneo_equipo', 'id_equipo', 'id_torneo'); + } +} diff --git a/app/Models/EquipoSeguimiento.php b/app/Models/EquipoSeguimiento.php new file mode 100644 index 0000000..79e1dc4 --- /dev/null +++ b/app/Models/EquipoSeguimiento.php @@ -0,0 +1,28 @@ + 'integer', + 'created_at' => 'datetime', + ]; + + public function equipo() + { + return $this->belongsTo(Equipo::class, 'id_equipo', 'id_equipo'); + } +} diff --git a/app/Models/Evento.php b/app/Models/Evento.php new file mode 100644 index 0000000..85c24b0 --- /dev/null +++ b/app/Models/Evento.php @@ -0,0 +1,96 @@ + 'string', + 'id_torneo' => 'integer', + 'id_equipo_local' => 'integer', + 'id_equipo_visitante' => 'integer', + 'marcador_local' => 'integer', + 'marcador_visitante' => 'integer', + 'precio' => 'decimal:2', + 'fase' => 'integer', + 'numero_partido_bracket' => 'integer', + ]; + + public function getFechaEventoAttribute($value) + { + return $value ? Carbon::parse($value) : null; + } + + public function getHoraInicioAttribute($value) + { + return $value ? Carbon::parse($value) : null; + } + + public function getHoraFinAttribute($value) + { + return $value ? Carbon::parse($value) : null; + } + + public function torneo() + { + return $this->belongsTo(Torneo::class, 'id_torneo'); + } + + public function equipoLocal() + { + return $this->belongsTo(Equipo::class, 'id_equipo_local', 'id_equipo'); + } + + public function equipoVisitante() + { + return $this->belongsTo(Equipo::class, 'id_equipo_visitante', 'id_equipo'); + } + + public function pagos() + { + return $this->hasMany(PagoMp::class, 'event_id', 'id_evento'); + } + + public function qrCodes() + { + return $this->hasMany(QrCode::class, 'id_evento', 'id_evento'); + } + + public function jugadoresPuntos() + { + return $this->hasMany(EventoJugador::class, 'id_evento', 'id_evento'); + } +} diff --git a/app/Models/EventoJugador.php b/app/Models/EventoJugador.php new file mode 100644 index 0000000..18dd668 --- /dev/null +++ b/app/Models/EventoJugador.php @@ -0,0 +1,27 @@ +belongsTo(Evento::class, 'id_evento', 'id_evento'); + } + + public function jugador() + { + return $this->belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } +} diff --git a/app/Models/Jugador.php b/app/Models/Jugador.php new file mode 100644 index 0000000..8bd86a8 --- /dev/null +++ b/app/Models/Jugador.php @@ -0,0 +1,79 @@ + 'string', + 'fecha_nacimiento' => 'date', + 'edad' => 'integer', + 'id_club_actual' => 'integer', + 'id_club_origen' => 'integer', + 'activo' => 'boolean', + 'reset_expira' => 'datetime', + ]; + + public function getCategoriaCalculadaAttribute() + { + if (!$this->fecha_nacimiento) return 'Sin categoría'; + + // Calculate age for the current year. (Categoría U15 is for players turning 14 and 15 in the current year). + // That means current_year - birth_year + $edadCategoria = date('Y') - $this->fecha_nacimiento->format('Y'); + + $categoria = Categoria::where('edad_min', '<=', $edadCategoria) + ->where('edad_max', '>=', $edadCategoria) + ->first(); + + return $categoria ? $categoria->nombre : 'Sin categoría'; + } + + public function clubActual() + { + return $this->belongsTo(Club::class, 'id_club_actual', 'id_club'); + } + + public function clubOrigen() + { + return $this->belongsTo(Club::class, 'id_club_origen', 'id_club'); + } + + public function equipos() + { + return $this->belongsToMany(Equipo::class, 'jugador_equipo', 'id_jugador', 'id_equipo') + ->withPivot('fecha_alta'); + } +} diff --git a/app/Models/JugadorEquipo.php b/app/Models/JugadorEquipo.php new file mode 100644 index 0000000..2efefa6 --- /dev/null +++ b/app/Models/JugadorEquipo.php @@ -0,0 +1,34 @@ + 'string', + 'id_equipo' => 'integer', + 'fecha_alta' => 'date', + ]; + + public function jugador() + { + return $this->belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } + + public function equipo() + { + return $this->belongsTo(Equipo::class, 'id_equipo', 'id_equipo'); + } +} diff --git a/app/Models/Noticia.php b/app/Models/Noticia.php new file mode 100644 index 0000000..f275db4 --- /dev/null +++ b/app/Models/Noticia.php @@ -0,0 +1,26 @@ + 'integer', + 'fecha' => 'datetime', + ]; +} diff --git a/app/Models/Notificacion.php b/app/Models/Notificacion.php new file mode 100644 index 0000000..1dcb753 --- /dev/null +++ b/app/Models/Notificacion.php @@ -0,0 +1,40 @@ + 'boolean', + 'enviada_email' => 'boolean', + 'creada_en' => 'datetime', + ]; + + // ── Scopes ── + public function scopeNoLeidas($query) + { + return $query->where('leida', false); + } + + public function scopeParaUsuario($query, string $tipo, $id) + { + return $query->where('tipo_destinatario', $tipo)->where('id_destinatario', (string)$id); + } +} diff --git a/app/Models/Pase.php b/app/Models/Pase.php new file mode 100644 index 0000000..0f0283e --- /dev/null +++ b/app/Models/Pase.php @@ -0,0 +1,33 @@ +belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } + + public function clubOrigen() + { + return $this->belongsTo(Club::class, 'id_club_origen', 'id_club'); + } + + public function clubDestino() + { + return $this->belongsTo(Club::class, 'id_club_destino', 'id_club'); + } +} diff --git a/app/Models/PromoQr.php b/app/Models/PromoQr.php new file mode 100644 index 0000000..e3287a7 --- /dev/null +++ b/app/Models/PromoQr.php @@ -0,0 +1,46 @@ + 'string', + 'id_promo' => 'integer', + 'id_usuario' => 'integer', + 'tipo_usuario' => 'string', + 'generado_en' => 'datetime', + 'usado' => 'boolean', + 'usado_en' => 'datetime', + ]; + + public function promocion() + { + return $this->belongsTo(Promocion::class, 'id_promo', 'id'); + } + + public function usuario() + { + if ($this->tipo_usuario === 'jugador') { + return $this->belongsTo(Jugador::class, 'id_usuario', 'id_jugador'); + } + return $this->belongsTo(Aficionado::class, 'id_usuario', 'id_aficionado'); + } +} diff --git a/app/Models/Promocion.php b/app/Models/Promocion.php new file mode 100644 index 0000000..3896198 --- /dev/null +++ b/app/Models/Promocion.php @@ -0,0 +1,35 @@ + 'integer', + 'lat' => 'decimal:8', + 'lng' => 'decimal:8', + ]; + + public function promoQrs() + { + return $this->hasMany(PromoQr::class, 'id_promo', 'id'); + } +} diff --git a/app/Models/PushSubscription.php b/app/Models/PushSubscription.php new file mode 100644 index 0000000..c137170 --- /dev/null +++ b/app/Models/PushSubscription.php @@ -0,0 +1,24 @@ +where('tipo_usuario', $tipo)->where('id_usuario', $id); + } +} diff --git a/app/Models/QrCode.php b/app/Models/QrCode.php new file mode 100644 index 0000000..484396a --- /dev/null +++ b/app/Models/QrCode.php @@ -0,0 +1,48 @@ + 'string', + 'id_evento' => 'string', + 'id_jugador' => 'string', + 'tipo_qr' => 'string', + 'escaneos_restantes' => 'integer', + 'creado' => 'datetime', + 'id_aficionado' => 'integer', + ]; + + public function evento() + { + return $this->belongsTo(Evento::class, 'id_evento', 'id_evento'); + } + + public function jugador() + { + return $this->belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } + + public function aficionado() + { + return $this->belongsTo(Aficionado::class, 'id_aficionado', 'id_aficionado'); + } +} diff --git a/app/Models/Sponsor.php b/app/Models/Sponsor.php new file mode 100644 index 0000000..2fb3743 --- /dev/null +++ b/app/Models/Sponsor.php @@ -0,0 +1,23 @@ + 'boolean', + ]; +} diff --git a/app/Models/Torneo.php b/app/Models/Torneo.php new file mode 100644 index 0000000..6447d9d --- /dev/null +++ b/app/Models/Torneo.php @@ -0,0 +1,33 @@ + 'datetime', + 'fecha_fin' => 'datetime', + ]; + + public function equipos() + { + return $this->belongsToMany(Equipo::class, 'torneo_equipo', 'id_torneo', 'id_equipo')->withPivot('grupo'); + } + + public function eventos() + { + return $this->hasMany(Evento::class, 'id_torneo'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..68f3a66 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,49 @@ + */ + use HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/app/Observers/EventoObserver.php b/app/Observers/EventoObserver.php new file mode 100644 index 0000000..e28c8d3 --- /dev/null +++ b/app/Observers/EventoObserver.php @@ -0,0 +1,142 @@ +notifService = $notifService; + } + + /** + * Al CREAR un partido: notificar a todos los seguidores de ambos equipos. + */ + public function created(Evento $evento): void + { + if (!$evento->id_equipo_local || !$evento->id_equipo_visitante) return; + + $evento->load(['equipoLocal.club', 'equipoVisitante.club']); + + $nombreLocal = $evento->equipoLocal->club->nombre ?? '?'; + $nombreVisitante= $evento->equipoVisitante->club->nombre ?? '?'; + $fechaStr = $evento->fecha_evento ? $evento->fecha_evento->format('d/m/Y') : '—'; + $horaStr = $evento->hora_inicio ? \Carbon\Carbon::parse($evento->hora_inicio)->format('H:i') : ''; + $sedeStr = $evento->sede ? " en {$evento->sede}" : ''; + + $titulo = "🏀 Nuevo Partido Programado"; + $mensaje = "{$nombreLocal} vs {$nombreVisitante} — {$fechaStr}" . ($horaStr ? " a las {$horaStr}" : '') . $sedeStr . '.'; + $url = '/eventos/' . $evento->id_evento; + + $this->notificarSeguidoresDeEquipos( + [$evento->id_equipo_local, $evento->id_equipo_visitante], + 'partido', + $titulo, + $mensaje, + $url + ); + } + + /** + * Al ACTUALIZAR un partido: si cambia el marcador, notificar resultado. + */ + public function updated(Evento $evento): void + { + $dirty = $evento->getDirty(); + + // Solo disparar si se actualizó el marcador + if (!array_key_exists('marcador_local', $dirty) && !array_key_exists('marcador_visitante', $dirty)) { + return; + } + + if ($evento->marcador_local === null || $evento->marcador_visitante === null) return; + + // Validación Horaria (Buenos Aires) + $tz = 'America/Argentina/Buenos_Aires'; + $ahora = \Carbon\Carbon::now($tz); + + // El usuario solicita que se emita cuando fin > real + $fecha = $evento->fecha_evento instanceof \Carbon\Carbon ? $evento->fecha_evento->format('Y-m-d') : substr($evento->fecha_evento, 0, 10); + $horaFin = $evento->hora_fin instanceof \Carbon\Carbon ? $evento->hora_fin->format('H:i:s') : $evento->hora_fin; + $fin = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$fecha} {$horaFin}", $tz); + + if (!($fin->gt($ahora))) { + return; + } + + $evento->load(['equipoLocal.club', 'equipoVisitante.club']); + + $nombreLocal = $evento->equipoLocal->club->nombre ?? '?'; + $nombreVisitante = $evento->equipoVisitante->club->nombre ?? '?'; + $mloc = $evento->marcador_local; + $mvis = $evento->marcador_visitante; + + if ($mloc > $mvis) { + $resultado = "🏆 Ganó {$nombreLocal}"; + } elseif ($mvis > $mloc) { + $resultado = "🏆 Ganó {$nombreVisitante}"; + } else { + $resultado = "🤝 Empate"; + } + + $titulo = "Resultado: {$nombreLocal} {$mloc} - {$mvis} {$nombreVisitante}"; + $mensaje = "{$resultado}. Partido finalizado."; + $url = '/eventos/' . $evento->id_evento; + + $this->notificarSeguidoresDeEquipos( + [$evento->id_equipo_local, $evento->id_equipo_visitante], + 'resultado', + $titulo, + $mensaje, + $url + ); + } + + /** + * Obtiene todos los seguidores de una lista de equipos y envía notificaciones. + * También incluye a los jugadores de esos equipos automáticamente. + */ + private function notificarSeguidoresDeEquipos(array $idEquipos, string $tipo, string $titulo, string $mensaje, ?string $url): void + { + $idEquipos = array_filter($idEquipos); + if (empty($idEquipos)) return; + + $destinatarios = []; + $yaAgregados = []; + + // Seguidores registrados en equipo_seguimiento + $seguimientos = EquipoSeguimiento::whereIn('id_equipo', $idEquipos)->get(); + foreach ($seguimientos as $s) { + $key = $s->tipo_usuario . ':' . $s->id_usuario; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => $s->tipo_usuario, 'id' => $s->id_usuario]; + $yaAgregados[$key] = true; + } + } + + // Jugadores que pertenecen a estos equipos (siguen automáticamente) + $jugadores = \DB::table('jugador_equipo') + ->whereIn('id_equipo', $idEquipos) + ->pluck('id_jugador'); + + foreach ($jugadores as $idJ) { + $key = 'jugador:' . $idJ; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => 'jugador', 'id' => $idJ]; + $yaAgregados[$key] = true; + } + } + + if (!empty($destinatarios)) { + $this->notifService->enviarMasivo($destinatarios, $tipo, $titulo, $mensaje, $url); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..f9857ed --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,57 @@ +app->singleton(NotificacionService::class); + } + + public function boot(): void + { + \Illuminate\Pagination\Paginator::useBootstrapFive(); + + // Registrar observer del modelo Evento + Evento::observe(EventoObserver::class); + + view()->composer('layouts.app', function ($view) { + try { + if (\Illuminate\Support\Facades\Schema::hasTable('sponsors')) { + $view->with('footerSponsors', \App\Models\Sponsor::where('activo', true)->orderBy('orden')->get()); + } else { + $view->with('footerSponsors', collect()); + } + + if (\Illuminate\Support\Facades\Schema::hasTable('torneos')) { + $view->with('navTorneos', \App\Models\Torneo::orderByDesc('fecha_inicio')->take(5)->get()); + } else { + $view->with('navTorneos', collect()); + } + + // Badge de notificaciones para el layout + if (session()->has('user_logged_in') && \Illuminate\Support\Facades\Schema::hasTable('notificaciones')) { + $service = app(NotificacionService::class); + $view->with('notifCount', $service->contarNoLeidas(session('user_tipo'), session('user_id'))); + } else { + $view->with('notifCount', 0); + } + + } catch (\Exception $e) { + $view->with('footerSponsors', collect()); + $view->with('navTorneos', collect()); + $view->with('notifCount', 0); + } + }); + } +} + diff --git a/app/Services/FixtureService.php b/app/Services/FixtureService.php new file mode 100644 index 0000000..5e0b581 --- /dev/null +++ b/app/Services/FixtureService.php @@ -0,0 +1,138 @@ +equipos()->get()->values()->toArray(); + $n = count($equipos); + + if ($n < 2) { + throw new \InvalidArgumentException('Se necesitan al menos 2 equipos para generar un fixture.'); + } + + // Si número impar, agregar BYE + $hayBye = false; + if ($n % 2 !== 0) { + $equipos[] = null; // BYE + $n++; + $hayBye = true; + } + + $mitad = $n / 2; + $jornadas = $n - 1; + $partidos = []; + $fechaActual = Carbon::parse($fechaInicio); + + // Algoritmo Round-Robin: fijar el primer equipo, rotar el resto + $lista = range(0, $n - 1); + + for ($jornada = 0; $jornada < $jornadas; $jornada++) { + $jornadaPartidos = []; + + for ($i = 0; $i < $mitad; $i++) { + $localIdx = $lista[$i]; + $visitanteIdx= $lista[$n - 1 - $i]; + + // Si alguno es BYE (null), saltar + if ($equipos[$localIdx] === null || $equipos[$visitanteIdx] === null) continue; + + $local = $equipos[$localIdx]; + $visitante = $equipos[$visitanteIdx]; + + // Alternar localía: en jornadas pares, invertir + if ($jornada % 2 === 1) { + [$local, $visitante] = [$visitante, $local]; + } + + $jornadaPartidos[] = [ + 'id_equipo_local' => $local['id_equipo'], + 'id_equipo_visitante' => $visitante['id_equipo'], + 'fecha_evento' => $fechaActual->format('Y-m-d'), + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'sede' => $sedeDefault, + 'id_torneo' => $torneo->id, + 'nombre_evento' => null, // se genera automáticamente + 'precio' => 0, + 'jornada' => $jornada + 1, + ]; + } + + $partidos = array_merge($partidos, $jornadaPartidos); + $fechaActual->addDays($diasEntreJornadas); + + // Rotar la lista (el primer elemento fijo) + $rotatable = array_slice($lista, 1); + array_push($rotatable, array_shift($rotatable)); + $lista = array_merge([$lista[0]], $rotatable); + } + + // Doble rueda: agregar vuelta con localías invertidas + if ($dobleRueda && !empty($partidos)) { + $vuelta = []; + foreach ($partidos as $p) { + $vuelta[] = array_merge($p, [ + 'id_equipo_local' => $p['id_equipo_visitante'], + 'id_equipo_visitante' => $p['id_equipo_local'], + 'fecha_evento' => Carbon::parse($p['fecha_evento'])->addDays($jornadas * $diasEntreJornadas)->format('Y-m-d'), + 'jornada' => $p['jornada'] + $jornadas, + ]); + } + $partidos = array_merge($partidos, $vuelta); + } + + return $partidos; + } + + /** + * Persiste el fixture generado como Evento records en la DB. + */ + public function persistirFixture(array $partidos, Torneo $torneo): int + { + $contador = 0; + foreach ($partidos as $p) { + $local = Equipo::with('club')->find($p['id_equipo_local']); + $visitante = Equipo::with('club')->find($p['id_equipo_visitante']); + + $nombreEvento = ($local->club->nombre ?? '?') . ' vs ' . ($visitante->club->nombre ?? '?'); + + \App\Models\Evento::create([ + 'id_evento' => uniqid('ev_'), + 'nombre_evento' => $nombreEvento, + 'id_equipo_local' => $p['id_equipo_local'], + 'id_equipo_visitante' => $p['id_equipo_visitante'], + 'fecha_evento' => $p['fecha_evento'], + 'hora_inicio' => $p['hora_inicio'], + 'hora_fin' => $p['hora_fin'], + 'sede' => $p['sede'] ?: null, + 'id_torneo' => $torneo->id, + 'precio' => $p['precio'] ?? 0, + ]); + + $contador++; + } + return $contador; + } +} diff --git a/app/Services/GeniusAgentService.php b/app/Services/GeniusAgentService.php new file mode 100644 index 0000000..f079732 --- /dev/null +++ b/app/Services/GeniusAgentService.php @@ -0,0 +1,229 @@ +buildMessages($history, $message); + + try { + $response = Prism::text() + ->using(self::PROVIDER, $this->model()) + ->withSystemPrompt(SystemPromptPublic::get()) + ->withMessages($messages) + ->asText(); + + return $response->text; + } catch (PrismRateLimitedException $e) { + report($e); + return 'Demasiadas consultas en poco tiempo. Por favor, esperá un momento e intentá de nuevo.'; + } catch (Throwable $e) { + report($e); + return 'El agente no responde en este momento. Por favor, intentá de nuevo en unos instantes.'; + } + } + + public function chatAdmin(string $message, AgentThread $thread, bool $isSuperadmin = false): string + { + $messages = $this->buildMessages($thread->messages ?? [], $message); + + try { + $response = Prism::text() + ->using(self::PROVIDER, $this->model()) + ->withSystemPrompt(SystemPromptAdmin::get($isSuperadmin)) + ->withMessages($messages) + ->withTools($this->getAdminTools($isSuperadmin)) + ->withMaxSteps(self::MAX_STEPS) + ->asText(); + + if ($response->text !== '') { + $reply = $response->text; + } else { + Log::warning('Genius admin: empty text from model', [ + 'finishReason' => isset($response->finishReason) + ? (is_object($response->finishReason) ? ($response->finishReason->name ?? get_class($response->finishReason)) : $response->finishReason) + : null, + 'steps_count' => is_countable($response->steps ?? null) ? count($response->steps) : null, + 'tool_calls' => is_countable($response->toolCalls ?? null) ? count($response->toolCalls) : null, + 'tool_results' => is_countable($response->toolResults ?? null) ? count($response->toolResults) : null, + ]); + $reply = $this->fallbackFromToolResults($response); + } + + $updated = array_merge($thread->messages ?? [], [ + ['role' => 'user', 'content' => $message], + ['role' => 'assistant', 'content' => $reply], + ]); + + $thread->update(['messages' => $updated]); + + return $reply; + } catch (PrismRateLimitedException $e) { + report($e); + return 'Demasiadas consultas en poco tiempo. Por favor, esperá un momento e intentá de nuevo.'; + } catch (Throwable $e) { + report($e); + return 'El agente no responde en este momento. Por favor, intentá de nuevo en unos instantes.'; + } + } + + private function fallbackFromToolResults($response): string + { + $collected = []; + + $steps = $response->steps ?? []; + foreach ($steps as $step) { + if (!empty($step->text ?? '')) { + return $step->text; + } + + foreach (($step->toolResults ?? []) as $tr) { + $collected[] = [ + 'tool' => $tr->toolName ?? ($tr->name ?? 'tool'), + 'result' => $tr->result ?? null, + ]; + } + } + + if (empty($collected)) { + foreach (($response->toolResults ?? []) as $tr) { + $collected[] = [ + 'tool' => $tr->toolName ?? ($tr->name ?? 'tool'), + 'result' => $tr->result ?? null, + ]; + } + } + + if (empty($collected)) { + return 'El agente no devolvió una respuesta. Revisá storage/logs/laravel.log para el detalle.'; + } + + $lines = ["Datos obtenidos (la IA no generó un resumen en texto):"]; + foreach ($collected as $item) { + $decoded = is_string($item['result']) ? json_decode($item['result'], true) : $item['result']; + $pretty = is_array($decoded) || is_object($decoded) + ? json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + : (string) $item['result']; + $lines[] = "\n**{$item['tool']}**:\n{$pretty}"; + } + + return implode("\n", $lines); + } + + /** @return array */ + private function buildMessages(array $history, string $newMessage): array + { + $limit = (int) config('services.genius.history_limit', 10); + $trimmed = $limit > 0 ? array_slice($history, -$limit * 2) : $history; + + $messages = []; + + foreach ($trimmed as $entry) { + if (($entry['role'] ?? '') === 'user') { + $messages[] = new UserMessage($entry['content']); + } elseif (($entry['role'] ?? '') === 'assistant') { + $messages[] = new AssistantMessage($entry['content']); + } + } + + $messages[] = new UserMessage($newMessage); + + return $messages; + } + + /** @return array */ + private function getAdminTools(bool $isSuperadmin): array + { + $readOnly = [ + Tool::as('listar_torneos') + ->for('Lista todos los torneos del sistema con sus IDs y fechas. Úsalo cuando el admin mencione un torneo por nombre para obtener el id_torneo.') + ->using(new ListarTorneosTool()), + + Tool::as('listar_equipos') + ->for('Lista los equipos del sistema. Acepta filtros opcionales por torneo (id_torneo) y grupo.') + ->withNumberParameter('id_torneo', 'ID del torneo para filtrar equipos', false) + ->withStringParameter('grupo', 'Nombre del grupo (ej. "A", "B") para filtrar dentro del torneo', false) + ->using(new ListarEquiposTool()), + + Tool::as('listar_eventos') + ->for('Lista los partidos/eventos del sistema. Acepta filtros opcionales por rango de fechas y torneo.') + ->withStringParameter('fecha_desde', 'Fecha de inicio del rango (formato YYYY-MM-DD)', false) + ->withStringParameter('fecha_hasta', 'Fecha de fin del rango (formato YYYY-MM-DD)', false) + ->withNumberParameter('id_torneo', 'ID del torneo para filtrar eventos', false) + ->using(new ListarEventosTool()), + ]; + + if (!$isSuperadmin) { + return $readOnly; + } + + $writeTools = [ + Tool::as('crear_partido') + ->for('Crea un nuevo partido en el sistema. SOLO ejecutar tras confirmación explícita del superadmin.') + ->withNumberParameter('id_equipo_local', 'ID del equipo local') + ->withNumberParameter('id_equipo_visitante', 'ID del equipo visitante') + ->withStringParameter('fecha_evento', 'Fecha del partido (formato YYYY-MM-DD)') + ->withStringParameter('hora_inicio', 'Hora de inicio (formato HH:MM)') + ->withStringParameter('hora_fin', 'Hora de fin (formato HH:MM)') + ->withStringParameter('sede', 'Nombre de la cancha o sede del partido') + ->withNumberParameter('id_torneo', 'ID del torneo al que pertenece el partido') + ->using(new CrearPartidoTool()), + + Tool::as('cargar_puntaje') + ->for('Actualiza el marcador de un partido existente. SOLO ejecutar tras confirmación explícita del superadmin.') + ->withStringParameter('id_evento', 'UUID del partido a actualizar') + ->withNumberParameter('marcador_local', 'Puntos del equipo local') + ->withNumberParameter('marcador_visitante', 'Puntos del equipo visitante') + ->using(new CargarPuntajeTool()), + + Tool::as('redactar_noticia') + ->for('Publica una noticia en el portal OnAPB. SOLO ejecutar tras confirmación explícita del superadmin.') + ->withStringParameter('titulo', 'Título de la noticia') + ->withStringParameter('contenido', 'Cuerpo completo de la noticia en HTML o texto plano') + ->withNumberParameter('id_torneo', 'ID del torneo relacionado (opcional)', false) + ->withStringParameter('categoria', 'Categoría de la noticia (opcional)', false) + ->using(new RedactarNoticiaTool()), + + Tool::as('eliminar_noticia') + ->for('Elimina (rollback) una noticia publicada. Usar cuando el superadmin pida deshacer una creación.') + ->withNumberParameter('id_noticia', 'ID numérico de la noticia a eliminar') + ->using(new EliminarNoticiaTool()), + + Tool::as('eliminar_partido') + ->for('Elimina (soft delete / rollback) un partido creado. Usar cuando el superadmin pida deshacer.') + ->withStringParameter('id_evento', 'UUID del partido a eliminar') + ->using(new EliminarPartidoTool()), + ]; + + return array_merge($readOnly, $writeTools); + } +} diff --git a/app/Services/ImageOptimizer.php b/app/Services/ImageOptimizer.php new file mode 100644 index 0000000..4c69f5f --- /dev/null +++ b/app/Services/ImageOptimizer.php @@ -0,0 +1,106 @@ + ['maxWidth' => 1600, 'quality' => 82], + 'noticias' => ['maxWidth' => 1200, 'quality' => 82], + 'promos' => ['maxWidth' => 1200, 'quality' => 82], + 'clubes' => ['maxWidth' => 512, 'quality' => 85], + 'sponsors' => ['maxWidth' => 600, 'quality' => 85], + 'qr' => ['maxWidth' => 800, 'quality' => 85], + ]; + + private const MIN_RECOMPRESS_BYTES = 100 * 1024; + + /** + * Sube el archivo al disk 'public' y lo optimiza in-place. + * Retorna el path relativo (ej: "carousel/abc123.jpg"). + */ + public function storeAndOptimize(UploadedFile $file, string $folder): string + { + $path = $file->store($folder, 'public'); + + if (!extension_loaded('gd')) { + return $path; + } + + $cfg = self::FOLDERS[$folder] ?? null; + if (!$cfg) { + return $path; + } + + $abs = Storage::disk('public')->path($path); + $this->optimizeFile($abs, $cfg['maxWidth'], $cfg['quality']); + + return $path; + } + + /** + * Optimiza un archivo en disco (resize + recompress). + * Devuelve [origSize, newSize, newW, newH] o null si no se modifico. + */ + public function optimizeFile(string $file, int $maxWidth, int $quality): ?array + { + if (!file_exists($file)) return null; + + $origSize = filesize($file); + $info = @getimagesize($file); + if (!$info) return null; + + [$w, $h] = $info; + $needsResize = $w > $maxWidth; + $needsRecomp = $origSize > self::MIN_RECOMPRESS_BYTES; + if (!$needsResize && !$needsRecomp) return null; + + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $img = match ($ext) { + 'jpg', 'jpeg' => @imagecreatefromjpeg($file), + 'png' => @imagecreatefrompng($file), + 'webp' => @imagecreatefromwebp($file), + default => null, + }; + if (!$img) return null; + + $newW = $w; $newH = $h; + if ($w > $maxWidth) { + $newW = $maxWidth; + $newH = (int) round($h * ($maxWidth / $w)); + $resized = imagecreatetruecolor($newW, $newH); + + if (in_array($ext, ['png', 'webp'])) { + imagealphablending($resized, false); + imagesavealpha($resized, true); + $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127); + imagefilledrectangle($resized, 0, 0, $newW, $newH, $transparent); + } + + imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + $img = $resized; + } + + ob_start(); + match ($ext) { + 'jpg', 'jpeg' => imagejpeg($img, null, $quality), + 'png' => imagepng($img, null, 9), + 'webp' => imagewebp($img, null, $quality), + }; + $bytes = ob_get_clean(); + + if (strlen($bytes) >= $origSize) { + return null; + } + + file_put_contents($file, $bytes); + return [$origSize, strlen($bytes), $newW, $newH]; + } +} diff --git a/app/Services/NotificacionService.php b/app/Services/NotificacionService.php new file mode 100644 index 0000000..985efe1 --- /dev/null +++ b/app/Services/NotificacionService.php @@ -0,0 +1,211 @@ + $tipo_dest, + 'id_destinatario' => (string) $id_dest, + 'tipo' => $tipo, + 'titulo' => $titulo, + 'mensaje' => $mensaje, + 'url_accion' => $url, + 'leida' => false, + 'enviada_email' => false, + 'creada_en' => now(), + ]); + + // Intentar envío Push + $this->enviarPush([['tipo' => $tipo_dest, 'id' => $id_dest]], $titulo, $mensaje, $url); + + return $notif; + } + + /** + * Enviar la misma notificación a múltiples destinatarios. + * $destinatarios = [['tipo' => 'jugador', 'id' => 'XXX'], ...] + */ + public function enviarMasivo(array $destinatarios, string $tipo, string $titulo, string $mensaje, ?string $url = null): int + { + $rows = []; + $ahora = now()->toDateTimeString(); + + foreach ($destinatarios as $dest) { + $rows[] = [ + 'tipo_destinatario' => $dest['tipo'], + 'id_destinatario' => (string) $dest['id'], + 'tipo' => $tipo, + 'titulo' => $titulo, + 'mensaje' => $mensaje, + 'url_accion' => $url, + 'leida' => false, + 'enviada_email' => false, + 'creada_en' => $ahora, + ]; + } + + if (empty($rows)) return 0; + + // Insert en chunks para no sobrecargar + foreach (array_chunk($rows, 500) as $chunk) { + Notificacion::insert($chunk); + } + + // Intentar envío Push masivo + $this->enviarPush($destinatarios, $titulo, $mensaje, $url); + + return count($rows); + } + + /** + * Lógica centralizada para enviar Web Push Notifications + */ + private function enviarPush(array $destinatarios, string $titulo, string $mensaje, ?string $url = null): void + { + $vPublic = env('VAPID_PUBLIC_KEY'); + $vPrivate = env('VAPID_PRIVATE_KEY'); + + if (!$vPublic || !$vPrivate) return; + + // Buscar todas las suscripciones de estos usuarios + $subsFound = PushSubscription::where(function($query) use ($destinatarios) { + foreach ($destinatarios as $d) { + $query->orWhere(function($q) use ($d) { + $q->where('tipo_usuario', $d['tipo']) + ->where('id_usuario', (string)$d['id']); + }); + } + })->get(); + + if ($subsFound->isEmpty()) return; + + try { + $auth = [ + 'VAPID' => [ + 'subject' => env('APP_URL', 'http://localhost'), + 'publicKey' => $vPublic, + 'privateKey' => $vPrivate, + ], + ]; + + $webPush = new WebPush($auth); + $payload = json_encode([ + 'title' => $titulo, + 'body' => $mensaje, + 'url' => $url ?: '/', + ]); + + foreach ($subsFound as $sub) { + $webPush->queueNotification( + Subscription::create([ + 'endpoint' => $sub->endpoint, + 'publicKey' => $sub->p256dh, + 'authToken' => $sub->auth, + ]), + $payload + ); + } + + foreach ($webPush->flush() as $report) { + if (!$report->isSuccess()) { + // Si falló (ej: suscripción expirada), la borramos para no reintentar + if ($report->isSubscriptionExpired()) { + $endpoint = $report->getEndpoint(); + PushSubscription::where('endpoint', $endpoint)->delete(); + } + } + } + } catch (\Exception $e) { + Log::error("Error enviando Web Push: " . $e->getMessage()); + } + } + + /** + * Obtener notificaciones no leídas de un usuario. + */ + public function obtenerNoLeidas(string $tipo, string|int $id): Collection + { + return Notificacion::paraUsuario($tipo, $id) + ->noLeidas() + ->orderByDesc('creada_en') + ->limit(50) + ->get(); + } + + /** + * Obtener todas las notificaciones de un usuario (paginadas). + */ + public function obtenerTodas(string $tipo, string|int $id, int $perPage = 20) + { + return Notificacion::paraUsuario($tipo, $id) + ->orderByDesc('creada_en') + ->paginate($perPage); + } + + /** + * Contar notificaciones no leídas. + */ + public function contarNoLeidas(string $tipo, string|int $id): int + { + return Notificacion::paraUsuario($tipo, $id)->noLeidas()->count(); + } + + /** + * Marcar una notificación como leída (verificando pertenencia). + */ + public function marcarLeida(int $id_notif, string $tipo, string|int $id_dest): bool + { + return (bool) Notificacion::paraUsuario($tipo, $id_dest) + ->where('id', $id_notif) + ->update(['leida' => true]); + } + + /** + * Marcar todas como leídas para un usuario. + */ + public function marcarTodasLeidas(string $tipo, string|int $id_dest): int + { + return Notificacion::paraUsuario($tipo, $id_dest) + ->noLeidas() + ->update(['leida' => true]); + } + + /** + * Eliminar una notificación (verificando pertenencia). + */ + public function eliminar(int $id_notif, string $tipo, string|int $id_dest): bool + { + return (bool) Notificacion::paraUsuario($tipo, $id_dest) + ->where('id', $id_notif) + ->delete(); + } + + /** + * Eliminar todas las notificaciones de un usuario. + */ + public function eliminarTodas(string $tipo, string|int $id_dest): int + { + return Notificacion::paraUsuario($tipo, $id_dest)->delete(); + } +} + diff --git a/app/Services/TournamentService.php b/app/Services/TournamentService.php new file mode 100644 index 0000000..cf42cb9 --- /dev/null +++ b/app/Services/TournamentService.php @@ -0,0 +1,149 @@ +findOrFail($idTorneo); + $stats = []; + + foreach ($torneo->equipos as $equipo) { + $groupName = $equipo->pivot->grupo ?: ($equipo->categoria . ' ' . $equipo->division); + + $stats[$groupName][$equipo->id_equipo] = [ + 'id' => $equipo->id_equipo, + 'nombre' => $equipo->club->nombre ?? 'Equipo', + 'logo' => $equipo->club->imagen ?? null, + 'categoria' => $equipo->categoria, + 'pj' => 0, + 'pg' => 0, + 'pp' => 0, + 'tf' => 0, + 'tc' => 0, + 'pts' => 0, + ]; + } + + $query = Evento::where('id_torneo', $idTorneo) + ->whereNotNull('marcador_local') + ->whereNotNull('marcador_visitante'); + + if ($onlyRegular) { + $query->where('fase', Evento::FASE_REGULAR); + } + + $matches = $query->get(); + + foreach ($matches as $m) { + $localGroup = null; + $visitGroup = null; + + foreach ($stats as $group => $teams) { + if (isset($teams[$m->id_equipo_local])) $localGroup = $group; + if (isset($teams[$m->id_equipo_visitante])) $visitGroup = $group; + } + + if (!$localGroup || !$visitGroup) continue; + + $stats[$localGroup][$m->id_equipo_local]['pj']++; + $stats[$localGroup][$m->id_equipo_local]['tf'] += $m->marcador_local; + $stats[$localGroup][$m->id_equipo_local]['tc'] += $m->marcador_visitante; + + $stats[$visitGroup][$m->id_equipo_visitante]['pj']++; + $stats[$visitGroup][$m->id_equipo_visitante]['tf'] += $m->marcador_visitante; + $stats[$visitGroup][$m->id_equipo_visitante]['tc'] += $m->marcador_local; + + if ($m->marcador_local > $m->marcador_visitante) { + $stats[$localGroup][$m->id_equipo_local]['pg']++; + $stats[$localGroup][$m->id_equipo_local]['pts'] += 2; + $stats[$visitGroup][$m->id_equipo_visitante]['pp']++; + $stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 1; + } elseif ($m->marcador_visitante > $m->marcador_local) { + $stats[$visitGroup][$m->id_equipo_visitante]['pg']++; + $stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 2; + $stats[$localGroup][$m->id_equipo_local]['pp']++; + $stats[$localGroup][$m->id_equipo_local]['pts'] += 1; + } else { + $stats[$localGroup][$m->id_equipo_local]['pts'] += 1; + $stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 1; + } + } + + foreach ($stats as $group => &$teams) { + usort($teams, function($a, $b) { + if ($b['pts'] !== $a['pts']) return $b['pts'] - $a['pts']; + $diffA = $a['tf'] - $a['tc']; + $diffB = $b['tf'] - $b['tc']; + if ($diffB !== $diffA) return $diffB - $diffA; + return $b['tf'] - $a['tf']; + }); + } + + return $stats; + } + + public function getPlayoffBrackets(int $idTorneo): array + { + $playoffs = Evento::where('id_torneo', $idTorneo) + ->where('fase', '>', Evento::FASE_REGULAR) + ->with(['equipoLocal.club', 'equipoVisitante.club']) + ->orderBy('fase') + ->orderBy('numero_partido_bracket') + ->orderBy('fecha_evento') + ->get(); + + $bracket = [ + Evento::FASE_CUARTOS => collect(), + Evento::FASE_SEMIS => collect(), + Evento::FASE_FINAL => collect(), + ]; + + // Agrupar por fase y número de llave (bracket) + $grouped = $playoffs->groupBy(function($item) { + return $item->fase . '-' . $item->numero_partido_bracket; + }); + + foreach ($grouped as $key => $matches) { + $first = $matches->first(); + $fase = $first->fase; + $nro = $first->numero_partido_bracket; + + $winsLocal = 0; + $winsVisit = 0; + $finished = 0; + + foreach ($matches as $m) { + if ($m->marcador_local !== null && $m->marcador_visitante !== null) { + $finished++; + if ($m->marcador_local > $m->marcador_visitante) $winsLocal++; + elseif ($m->marcador_visitante > $m->marcador_local) $winsVisit++; + } + } + + $bracket[$fase][$nro] = [ + 'matches' => $matches, + 'wins_local' => $winsLocal, + 'wins_visitante' => $winsVisit, + 'total_partidos' => $matches->count(), + 'terminados' => $finished, + 'equipo_local' => $first->equipoLocal, + 'equipo_visitante' => $first->equipoVisitante, + ]; + } + + return $bracket; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..8905daa --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,72 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->web(append: [ + \App\Http\Middleware\SecurityHeaders::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // Renderer amigable para errores en /admin/*: convierte excepciones técnicas + // (especialmente SQL) en una redirección back() con un mensaje claro que el + // layout admin muestra como SweetAlert modal. Detalle técnico va al log. + $exceptions->render(function (\Throwable $e, $request) { + if (config('app.debug')) { + return null; // En local/debug mantenemos la pantalla detallada de Laravel. + } + if (!$request->is('admin/*')) { + return null; + } + if ($request->expectsJson() || $request->ajax()) { + return null; + } + + // Excepciones que Laravel ya maneja bien (validación, auth, 404, etc.). + $passthrough = [ + \Illuminate\Validation\ValidationException::class, + \Illuminate\Auth\AuthenticationException::class, + \Illuminate\Auth\Access\AuthorizationException::class, + \Illuminate\Session\TokenMismatchException::class, + \Symfony\Component\HttpKernel\Exception\HttpException::class, + \Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class, + \Illuminate\Database\Eloquent\ModelNotFoundException::class, + ]; + foreach ($passthrough as $cls) { + if ($e instanceof $cls) return null; + } + + if ($e instanceof \Illuminate\Database\QueryException) { + $code = (int) ($e->errorInfo[1] ?? 0); + $msg = match (true) { + $code === 1062 => 'Ya existe un registro con esos datos. Verificá si no fue cargado previamente o si se encuentra en la papelera.', + in_array($code, [1451, 1452], true) => 'Esta operación no se puede completar porque existen datos relacionados (jugadores, equipos o eventos vinculados).', + default => 'Hubo un problema con la base de datos. Intentá nuevamente o contactá al administrador.', + }; + \Log::error('[admin] QueryException', [ + 'code' => $code, + 'message' => $e->getMessage(), + 'url' => $request->fullUrl(), + ]); + return back()->withInput()->with('admin_error_modal', $msg); + } + + \Log::error('[admin] Excepción no manejada', [ + 'exception' => $e->getMessage(), + 'class' => get_class($e), + 'url' => $request->fullUrl(), + ]); + return back()->withInput()->with( + 'admin_error_modal', + 'Ocurrió un error inesperado al procesar la solicitud. Intentá nuevamente o contactá al administrador.' + ); + }); + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => env('APP_TIMEZONE', 'UTC'), + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'es'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'es'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'es_AR'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/backup.php b/config/backup.php new file mode 100644 index 0000000..b8a7c13 --- /dev/null +++ b/config/backup.php @@ -0,0 +1,387 @@ + [ + /* + * The name of this application. You can use this name to monitor + * the backups. + */ + 'name' => env('APP_NAME', 'laravel-backup'), + + 'source' => [ + 'files' => [ + /* + * The list of directories and files that will be included in the backup. + */ + 'include' => [ + base_path(), + // storage_path(), // Include if you use zero downtime deployments and don't follow symlinks + ], + + /* + * These directories and files will be excluded from the backup. + * + * Directories used by the backup process will automatically be excluded. + */ + 'exclude' => [ + base_path('vendor'), + base_path('node_modules'), + storage_path('framework'), + ], + + /* + * Determines if symlinks should be followed. + */ + 'follow_links' => false, + + /* + * Determines if it should avoid unreadable folders. + */ + 'ignore_unreadable_directories' => false, + + /* + * This path is used to make directories in resulting zip-file relative + * Set to `null` to include complete absolute path + * Example: base_path() + */ + 'relative_path' => null, + ], + + /* + * The names of the connections to the databases that should be backed up + * MySQL, PostgreSQL, SQLite and Mongo databases are supported. + * + * The content of the database dump may be customized for each connection + * by adding a 'dump' key to the connection settings in config/database.php. + * E.g. + * 'mysql' => [ + * ... + * 'dump' => [ + * 'exclude_tables' => [ + * 'table_to_exclude_from_backup', + * 'another_table_to_exclude' + * ] + * ], + * ], + * + * If you are using only InnoDB tables on a MySQL server, you can + * also supply the useSingleTransaction option to avoid table locking. + * + * E.g. + * 'mysql' => [ + * ... + * 'dump' => [ + * 'useSingleTransaction' => true, + * ], + * ], + * + * For a complete list of available customization options, see https://github.com/spatie/db-dumper + */ + 'databases' => [ + env('DB_CONNECTION', 'mysql'), + ], + ], + + /* + * The database dump can be compressed to decrease disk space usage. + * + * Out of the box Laravel-backup supplies + * Spatie\DbDumper\Compressors\GzipCompressor::class. + * + * You can also create custom compressor. More info on that here: + * https://github.com/spatie/db-dumper#using-compression + * + * If you do not want any compressor at all, set it to null. + */ + 'database_dump_compressor' => null, + + /* + * If specified, the database dumped file name will contain a timestamp (e.g.: 'Y-m-d-H-i-s'). + */ + 'database_dump_file_timestamp_format' => null, + + /* + * The base of the dump filename, either 'database' or 'connection' + * + * If 'database' (default), the dumped filename will contain the database name. + * If 'connection', the dumped filename will contain the connection name. + */ + 'database_dump_filename_base' => 'database', + + /* + * The file extension used for the database dump files. + * + * If not specified, the file extension will be .archive for MongoDB and .sql for all other databases + * The file extension should be specified without a leading . + */ + 'database_dump_file_extension' => '', + + 'destination' => [ + /* + * The compression algorithm to be used for creating the zip archive. + * + * If backing up only database, you may choose gzip compression for db dump and no compression at zip. + * + * Some common algorithms are listed below: + * ZipArchive::CM_STORE (no compression at all; set 0 as compression level) + * ZipArchive::CM_DEFAULT + * ZipArchive::CM_DEFLATE + * ZipArchive::CM_BZIP2 + * ZipArchive::CM_XZ + * + * For more check https://www.php.net/manual/zip.constants.php and confirm it's supported by your system. + */ + 'compression_method' => ZipArchive::CM_DEFAULT, + + /* + * The compression level corresponding to the used algorithm; an integer between 0 and 9. + * + * Check supported levels for the chosen algorithm, usually 1 means the fastest and weakest compression, + * while 9 the slowest and strongest one. + * + * Setting of 0 for some algorithms may switch to the strongest compression. + */ + 'compression_level' => 9, + + /* + * The filename prefix used for the backup zip file. + */ + 'filename_prefix' => '', + + /* + * The disk names on which the backups will be stored. + */ + 'disks' => [ + 'local', + ], + + /* + * Determines whether to allow backups to continue when some targets fail instead of failing completely. + */ + 'continue_on_failure' => false, + ], + + /* + * The directory where the temporary files will be stored. + */ + 'temporary_directory' => storage_path('app/backup-temp'), + + /* + * The password to be used for archive encryption. + * Set to `null` to disable encryption. + */ + 'password' => env('BACKUP_ARCHIVE_PASSWORD'), + + /* + * The encryption algorithm to be used for archive encryption. + * Set to 'none' to disable encryption. + * + * Supported: 'none', 'default', 'aes128', 'aes192', 'aes256' + * + * When set to 'default', we'll use AES-256 if available on your system. + */ + 'encryption' => 'default', + + /* + * After creating the zip, verify it can be opened and contains files. + * Recommended for critical backups but adds a small overhead. + */ + 'verify_backup' => false, + + /* + * The number of attempts, in case the backup command encounters an exception + */ + 'tries' => 1, + + /* + * The number of seconds to wait before attempting a new backup if the previous try failed + * Set to `0` for none + */ + 'retry_delay' => 0, + ], + + /* + * You can get notified when specific events occur. Out of the box you can use 'mail' and 'slack'. + * For Slack you need to install laravel/slack-notification-channel. + * + * You can also use your own notification classes, just make sure the class is named after one of + * the `Spatie\Backup\Notifications\Notifications` classes. + */ + 'notifications' => [ + 'notifications' => [ + BackupHasFailedNotification::class => ['mail'], + UnhealthyBackupWasFoundNotification::class => ['mail'], + CleanupHasFailedNotification::class => ['mail'], + BackupWasSuccessfulNotification::class => env('BACKUP_NOTIFY_SUCCESS', false) ? ['mail'] : [], + HealthyBackupWasFoundNotification::class => env('BACKUP_NOTIFY_SUCCESS', false) ? ['mail'] : [], + CleanupWasSuccessfulNotification::class => env('BACKUP_NOTIFY_SUCCESS', false) ? ['mail'] : [], + ], + + /* + * Here you can specify the notifiable to which the notifications should be sent. The default + * notifiable will use the variables specified in this config file. + */ + 'notifiable' => Notifiable::class, + + 'mail' => [ + 'to' => env('BACKUP_NOTIFICATION_EMAIL', env('MAIL_FROM_ADDRESS')), + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + ], + + 'slack' => [ + 'webhook_url' => '', + + /* + * If this is set to null the default channel of the webhook will be used. + */ + 'channel' => null, + + 'username' => null, + + 'icon' => null, + ], + + 'discord' => [ + 'webhook_url' => '', + + /* + * If this is an empty string, the name field on the webhook will be used. + */ + 'username' => '', + + /* + * If this is an empty string, the avatar on the webhook will be used. + */ + 'avatar_url' => '', + ], + + /* + * A generic webhook channel that POSTs JSON to a URL. + * Useful for Mattermost, Microsoft Teams, or custom integrations. + */ + 'webhook' => [ + 'url' => '', + ], + ], + + /* + * The log channel used for backup activity messages. + * + * Set to a channel name defined in config/logging.php to use that channel. + * Set to false to disable backup logging entirely. + * Set to null to use the default log channel. + */ + 'log_channel' => null, + + /* + * Here you can specify which backups should be monitored. + * If a backup does not meet the specified requirements the + * UnHealthyBackupWasFound event will be fired. + */ + 'monitor_backups' => [ + [ + 'name' => env('APP_NAME', 'laravel-backup'), + 'disks' => ['local'], + 'health_checks' => [ + MaximumAgeInDays::class => 1, + MaximumStorageInMegabytes::class => 5000, + ], + ], + + /* + [ + 'name' => 'name of the second app', + 'disks' => ['local', 's3'], + 'health_checks' => [ + \Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumAgeInDays::class => 1, + \Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumStorageInMegabytes::class => 5000, + ], + ], + */ + ], + + 'cleanup' => [ + /* + * The strategy that will be used to cleanup old backups. The default strategy + * will keep all backups for a certain amount of days. After that period only + * a daily backup will be kept. After that period only weekly backups will + * be kept and so on. + * + * No matter how you configure it the default strategy will never + * delete the newest backup. + */ + 'strategy' => DefaultStrategy::class, + + 'default_strategy' => [ + /* + * Política OnAPB: mantener solo la última semana de backups. + * Resultado esperado en estado estable: máximo ~7 backups + * (uno por día), con tope duro de 1 GB. + */ + + /* + * Días para los que se conservan TODOS los backups del día. + * Con backup:run diario, normalmente solo hay 1 por día, + * así que esto cubre los últimos 3 días por seguridad. + */ + 'keep_all_backups_for_days' => 3, + + /* + * Después de los "all", se conserva solo el backup más reciente + * de cada día durante esta cantidad de días. + * 7 días = última semana de backups disponibles. + */ + 'keep_daily_backups_for_days' => 7, + + /* + * Sin backups semanales históricos (se borran al pasar la semana). + */ + 'keep_weekly_backups_for_weeks' => 0, + + /* + * Sin backups mensuales históricos. + */ + 'keep_monthly_backups_for_months' => 0, + + /* + * Sin backups anuales históricos. + */ + 'keep_yearly_backups_for_years' => 0, + + /* + * Tope duro de espacio: si la carpeta de backups supera 1 GB, + * borrar los más antiguos hasta volver bajo el límite. + * Esto cubre el caso de un backup que crezca mucho de golpe. + */ + 'delete_oldest_backups_when_using_more_megabytes_than' => 1000, + ], + + /* + * The number of attempts, in case the cleanup command encounters an exception + */ + 'tries' => 1, + + /* + * The number of seconds to wait before attempting a new cleanup if the previous try failed + * Set to `0` for none + */ + 'retry_delay' => 0, + ], + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/config/cache.php @@ -0,0 +1,117 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", + | "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..64709ce --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..545d68d --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,81 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + // Le decimos que salga de la carpeta laravel y guarde directo en public_html + 'root' => base_path('../public_html/storage'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..9e998a4 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/prism.php b/config/prism.php new file mode 100644 index 0000000..99f3ea5 --- /dev/null +++ b/config/prism.php @@ -0,0 +1,73 @@ + [ + // The middleware that will be applied to the Prism Server routes. + 'middleware' => [], + 'enabled' => env('PRISM_SERVER_ENABLED', false), + ], + 'request_timeout' => env('PRISM_REQUEST_TIMEOUT', 30), // The timeout for requests in seconds. + 'providers' => [ + 'openai' => [ + 'url' => env('OPENAI_URL', 'https://api.openai.com/v1'), + 'api_key' => env('OPENAI_API_KEY', ''), + 'organization' => env('OPENAI_ORGANIZATION', null), + 'project' => env('OPENAI_PROJECT', null), + ], + 'anthropic' => [ + 'api_key' => env('ANTHROPIC_API_KEY', ''), + 'version' => env('ANTHROPIC_API_VERSION', '2023-06-01'), + 'url' => env('ANTHROPIC_URL', 'https://api.anthropic.com/v1'), + 'default_thinking_budget' => env('ANTHROPIC_DEFAULT_THINKING_BUDGET', 1024), + // Include beta strings as a comma separated list. + 'anthropic_beta' => env('ANTHROPIC_BETA', null), + ], + 'ollama' => [ + 'url' => env('OLLAMA_URL', 'http://localhost:11434'), + ], + 'mistral' => [ + 'api_key' => env('MISTRAL_API_KEY', ''), + 'url' => env('MISTRAL_URL', 'https://api.mistral.ai/v1'), + ], + 'groq' => [ + 'api_key' => env('GROQ_API_KEY', ''), + 'url' => env('GROQ_URL', 'https://api.groq.com/openai/v1'), + ], + 'xai' => [ + 'api_key' => env('XAI_API_KEY', ''), + 'url' => env('XAI_URL', 'https://api.x.ai/v1'), + ], + 'gemini' => [ + 'api_key' => env('GEMINI_API_KEY', ''), + 'url' => env('GEMINI_URL', 'https://generativelanguage.googleapis.com/v1beta/models'), + ], + 'deepseek' => [ + 'api_key' => env('DEEPSEEK_API_KEY', ''), + 'url' => env('DEEPSEEK_URL', 'https://api.deepseek.com/v1'), + ], + 'elevenlabs' => [ + 'api_key' => env('ELEVENLABS_API_KEY', ''), + 'url' => env('ELEVENLABS_URL', 'https://api.elevenlabs.io/v1/'), + ], + 'voyageai' => [ + 'api_key' => env('VOYAGEAI_API_KEY', ''), + 'url' => env('VOYAGEAI_URL', 'https://api.voyageai.com/v1'), + ], + 'openrouter' => [ + 'api_key' => env('OPENROUTER_API_KEY', ''), + 'url' => env('OPENROUTER_URL', 'https://openrouter.ai/api/v1'), + 'site' => [ + 'http_referer' => env('OPENROUTER_SITE_HTTP_REFERER', null), + 'x_title' => env('OPENROUTER_SITE_X_TITLE', null), + ], + ], + 'perplexity' => [ + 'api_key' => env('PERPLEXITY_API_KEY', ''), + 'url' => env('PERPLEXITY_URL', 'https://api.perplexity.ai'), + ], + 'z' => [ + 'url' => env('Z_URL', 'https://api.z.ai/api/paas/v4'), + 'api_key' => env('Z_API_KEY', ''), + ], + ], +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..fa81871 --- /dev/null +++ b/config/services.php @@ -0,0 +1,50 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + + 'turnstile' => [ + 'site_key' => env('TURNSTILE_SITE_KEY'), + 'secret_key' => env('TURNSTILE_SECRET_KEY'), + ], + + 'genius' => [ + 'model' => env('GENIUS_MODEL', 'gemini-2.5-flash-lite'), + 'history_limit' => (int) env('GENIUS_HISTORY_LIMIT', 10), + 'max_messages_per_session' => (int) env('GENIUS_MAX_MESSAGES_PER_SESSION', 20), + 'session_window_minutes' => (int) env('GENIUS_SESSION_WINDOW_MINUTES', 60), + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..5b541b7 --- /dev/null +++ b/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..05fb5d9 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..ed758bd --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..967fbf5 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue'); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + + $table->index(['queue', 'reserved_at', 'available_at']); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_03_22_000228_create_carousel_items_table.php b/database/migrations/2026_03_22_000228_create_carousel_items_table.php new file mode 100644 index 0000000..7f3b235 --- /dev/null +++ b/database/migrations/2026_03_22_000228_create_carousel_items_table.php @@ -0,0 +1,71 @@ +id(); + $table->string('titulo')->nullable(); + $table->string('subtitulo')->nullable(); + $table->string('boton_texto')->nullable(); + $table->string('boton_enlace')->nullable(); + $table->string('imagen'); + $table->integer('orden')->default(0); + $table->boolean('activo')->default(true); + $table->timestamps(); + }); + + // Insert default items to preserve the first 3 original items + DB::table('carousel_items')->insert([ + [ + 'titulo' => 'Bienvenido a OnAPB', + 'subtitulo' => 'La nueva forma de vivir el básquet en Paraná', + 'boton_texto' => 'Ver partidos', + 'boton_enlace' => '/eventos', + 'imagen' => 'hero1.jpeg', + 'orden' => 1, + 'activo' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'titulo' => 'El basquet en tus manos', + 'subtitulo' => 'Seguilo como nunca antes', + 'boton_texto' => 'Unite', + 'boton_enlace' => '/asociate', + 'imagen' => 'hero2.jpg', + 'orden' => 2, + 'activo' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'titulo' => 'Sumate a la comunidad', + 'subtitulo' => 'Asociate y disfrutá beneficios exclusivos', + 'boton_texto' => 'Lugares', + 'boton_enlace' => '/promos', + 'imagen' => 'hero3.jpg', + 'orden' => 3, + 'activo' => true, + 'created_at' => now(), + 'updated_at' => now(), + ] + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carousel_items'); + } +}; diff --git a/database/migrations/2026_03_22_011653_add_id_club_to_admin_users_table.php b/database/migrations/2026_03_22_011653_add_id_club_to_admin_users_table.php new file mode 100644 index 0000000..64671fd --- /dev/null +++ b/database/migrations/2026_03_22_011653_add_id_club_to_admin_users_table.php @@ -0,0 +1,41 @@ +integer('id_club')->nullable()->after('password'); + } else { + // Si la columna ya se creó en error en un intento anterior pero mal tipada + $table->integer('id_club')->nullable()->change(); + } + // Agregamos la constrain después de asegurarnos de su tipo + }); + + Schema::table('admin_users', function (Blueprint $table) { + // Intentar dropear llave si existe para evitar error por si acaso no va, + // pero como falló en el fk, mejor tratamos de crearla: + $table->foreign('id_club')->references('id_club')->on('clubes')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('admin_users', function (Blueprint $table) { + $table->dropForeign(['id_club']); + $table->dropColumn('id_club'); + }); + } +}; diff --git a/database/migrations/2026_03_22_011702_create_categorias_table.php b/database/migrations/2026_03_22_011702_create_categorias_table.php new file mode 100644 index 0000000..b98de9c --- /dev/null +++ b/database/migrations/2026_03_22_011702_create_categorias_table.php @@ -0,0 +1,31 @@ +id('id_categoria'); + $table->string('nombre'); + $table->integer('edad_min'); + $table->integer('edad_max'); + $table->string('genero')->nullable(); // M, F, Mixto... + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categorias'); + } +}; diff --git a/database/migrations/2026_03_22_011710_create_pases_table.php b/database/migrations/2026_03_22_011710_create_pases_table.php new file mode 100644 index 0000000..8061a1d --- /dev/null +++ b/database/migrations/2026_03_22_011710_create_pases_table.php @@ -0,0 +1,37 @@ +id('id_pase'); + $table->char('id_jugador', 36)->nullable(); + $table->integer('id_club_origen')->nullable(); + $table->integer('id_club_destino')->nullable(); + $table->string('estado')->default('Pendiente'); // Pendiente, Aprobado, Rechazado + + // Si la FK en jugadores es de tipo uuid, usamos char(36) + $table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('cascade'); + $table->foreign('id_club_origen')->references('id_club')->on('clubes')->onDelete('cascade'); + $table->foreign('id_club_destino')->references('id_club')->on('clubes')->onDelete('cascade'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pases'); + } +}; diff --git a/database/migrations/2026_03_22_023553_add_limite_qr_to_eventos_table.php b/database/migrations/2026_03_22_023553_add_limite_qr_to_eventos_table.php new file mode 100644 index 0000000..b7b7e03 --- /dev/null +++ b/database/migrations/2026_03_22_023553_add_limite_qr_to_eventos_table.php @@ -0,0 +1,22 @@ +integer('limite_qr_jugador')->default(3)->after('precio'); + }); + } + + public function down(): void + { + Schema::table('eventos', function (Blueprint $table) { + $table->dropColumn('limite_qr_jugador'); + }); + } +}; diff --git a/database/migrations/2026_03_22_023601_add_qr_fields_to_clubes_table.php b/database/migrations/2026_03_22_023601_add_qr_fields_to_clubes_table.php new file mode 100644 index 0000000..a4ebdf5 --- /dev/null +++ b/database/migrations/2026_03_22_023601_add_qr_fields_to_clubes_table.php @@ -0,0 +1,23 @@ +string('qr_background')->nullable(); + $table->string('qr_color_texto')->default('#000000'); + }); + } + + public function down(): void + { + Schema::table('clubes', function (Blueprint $table) { + $table->dropColumn(['qr_background', 'qr_color_texto']); + }); + } +}; diff --git a/database/migrations/2026_03_22_023610_add_es_libre_to_categorias_table.php b/database/migrations/2026_03_22_023610_add_es_libre_to_categorias_table.php new file mode 100644 index 0000000..4ba7750 --- /dev/null +++ b/database/migrations/2026_03_22_023610_add_es_libre_to_categorias_table.php @@ -0,0 +1,22 @@ +boolean('es_libre')->default(false); + }); + } + + public function down(): void + { + Schema::table('categorias', function (Blueprint $table) { + $table->dropColumn('es_libre'); + }); + } +}; diff --git a/database/migrations/2026_03_22_044322_create_configuraciones_table.php b/database/migrations/2026_03_22_044322_create_configuraciones_table.php new file mode 100644 index 0000000..d5bc4cd --- /dev/null +++ b/database/migrations/2026_03_22_044322_create_configuraciones_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('clave')->unique(); + $table->text('valor')->nullable(); + $table->string('descripcion')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('configuraciones'); + } +}; diff --git a/database/migrations/2026_03_22_113000_increase_id_jugador_length.php b/database/migrations/2026_03_22_113000_increase_id_jugador_length.php new file mode 100644 index 0000000..486f208 --- /dev/null +++ b/database/migrations/2026_03_22_113000_increase_id_jugador_length.php @@ -0,0 +1,74 @@ +dropForeign('jugador_equipo_id_jugador_foreign'); + }); + } catch (\Exception $e) {} + + try { + // Drop confirmed name + DB::statement('ALTER TABLE jugador_equipo DROP FOREIGN KEY jugador_equipo_ibfk_1'); + } catch (\Exception $e) {} + + try { + DB::statement('ALTER TABLE qr_codes DROP FOREIGN KEY qr_codes_ibfk_2'); + } catch (\Exception $e) {} + + // 2. Increase the length of id_jugador in the main table + Schema::table('jugadores', function (Blueprint $table) { + $table->string('id_jugador', 20)->change(); + }); + + // 3. Increase the length in referencing tables + Schema::table('pases', function (Blueprint $table) { + $table->string('id_jugador', 20)->change(); + }); + + Schema::table('jugador_equipo', function (Blueprint $table) { + $table->string('id_jugador', 20)->change(); + }); + + Schema::table('qr_codes', function (Blueprint $table) { + $table->string('id_jugador', 20)->nullable()->change(); + }); + + // 4. Restore foreign keys (Laravel will pick names) + Schema::table('pases', function (Blueprint $table) { + // Check if column exists and is not already a FK + try { + $table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('cascade'); + } catch (\Exception $e) {} + }); + + Schema::table('jugador_equipo', function (Blueprint $table) { + $table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('cascade'); + }); + + Schema::table('qr_codes', function (Blueprint $table) { + $table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // No revert needed for length increase + } +}; diff --git a/database/migrations/2026_03_22_114500_add_imagen_to_clubes.php b/database/migrations/2026_03_22_114500_add_imagen_to_clubes.php new file mode 100644 index 0000000..106c8ab --- /dev/null +++ b/database/migrations/2026_03_22_114500_add_imagen_to_clubes.php @@ -0,0 +1,32 @@ +string('imagen')->nullable()->after('nombre'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('clubes', function (Blueprint $table) { + if (Schema::hasColumn('clubes', 'imagen')) { + $table->dropColumn('imagen'); + } + }); + } +}; diff --git a/database/migrations/2026_03_22_161609_create_sponsors_table.php b/database/migrations/2026_03_22_161609_create_sponsors_table.php new file mode 100644 index 0000000..b2b5d70 --- /dev/null +++ b/database/migrations/2026_03_22_161609_create_sponsors_table.php @@ -0,0 +1,32 @@ +id('id_sponsor'); + $blueprint->string('nombre', 100); + $blueprint->string('imagen', 255); + $blueprint->string('url', 255)->nullable(); + $blueprint->boolean('activo')->default(true); + $blueprint->integer('orden')->default(0); + $blueprint->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sponsors'); + } +}; diff --git a/database/migrations/2026_03_24_062014_create_torneos_table.php b/database/migrations/2026_03_24_062014_create_torneos_table.php new file mode 100644 index 0000000..166366a --- /dev/null +++ b/database/migrations/2026_03_24_062014_create_torneos_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('nombre'); + $table->date('fecha_inicio')->nullable(); + $table->date('fecha_fin')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('torneos'); + } +}; diff --git a/database/migrations/2026_03_24_062015_create_torneo_equipo_table.php b/database/migrations/2026_03_24_062015_create_torneo_equipo_table.php new file mode 100644 index 0000000..45b295f --- /dev/null +++ b/database/migrations/2026_03_24_062015_create_torneo_equipo_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('id_torneo')->constrained('torneos')->onDelete('cascade'); + $table->unsignedBigInteger('id_equipo'); + $table->foreign('id_equipo')->references('id_equipo')->on('equipos')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('torneo_equipo'); + } +}; diff --git a/database/migrations/2026_03_24_062016_add_id_torneo_to_eventos_table.php b/database/migrations/2026_03_24_062016_add_id_torneo_to_eventos_table.php new file mode 100644 index 0000000..bc2d7bd --- /dev/null +++ b/database/migrations/2026_03_24_062016_add_id_torneo_to_eventos_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('id_torneo')->nullable()->after('id_evento'); + $table->foreign('id_torneo')->references('id')->on('torneos')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('eventos', function (Blueprint $table) { + $table->dropForeign(['id_torneo']); + $table->dropColumn('id_torneo'); + }); + } +}; diff --git a/database/migrations/2026_03_24_062511_add_scores_to_eventos_table.php b/database/migrations/2026_03_24_062511_add_scores_to_eventos_table.php new file mode 100644 index 0000000..8a8e89f --- /dev/null +++ b/database/migrations/2026_03_24_062511_add_scores_to_eventos_table.php @@ -0,0 +1,29 @@ +integer('marcador_local')->nullable()->default(0)->after('id_equipo_visitante'); + $col->integer('marcador_visitante')->nullable()->default(0)->after('marcador_local'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('eventos', function (Blueprint $col) { + $col->dropColumn(['marcador_local', 'marcador_visitante']); + }); + } +}; diff --git a/database/migrations/2026_03_24_062734_create_evento_jugador_table.php b/database/migrations/2026_03_24_062734_create_evento_jugador_table.php new file mode 100644 index 0000000..86cae92 --- /dev/null +++ b/database/migrations/2026_03_24_062734_create_evento_jugador_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('id_evento'); + $table->string('id_jugador'); + $table->integer('puntos')->default(0); + $table->integer('faltas')->default(0); + $table->timestamps(); + + $table->foreign('id_evento')->references('id_evento')->on('eventos')->onDelete('cascade'); + $table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('cascade'); + + // Un jugador solo puede tener un registro de puntos por evento + $table->unique(['id_evento', 'id_jugador']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('evento_jugador'); + } +}; diff --git a/database/migrations/2026_03_24_065226_add_grupo_to_torneo_equipo_table.php b/database/migrations/2026_03_24_065226_add_grupo_to_torneo_equipo_table.php new file mode 100644 index 0000000..3caaefe --- /dev/null +++ b/database/migrations/2026_03_24_065226_add_grupo_to_torneo_equipo_table.php @@ -0,0 +1,28 @@ +string('grupo')->nullable()->after('id_equipo'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('torneo_equipo', function (Blueprint $table) { + $table->dropColumn('grupo'); + }); + } +}; diff --git a/database/migrations/2026_03_25_181950_add_soft_deletes_to_critical_tables.php b/database/migrations/2026_03_25_181950_add_soft_deletes_to_critical_tables.php new file mode 100644 index 0000000..51e9cd6 --- /dev/null +++ b/database/migrations/2026_03_25_181950_add_soft_deletes_to_critical_tables.php @@ -0,0 +1,52 @@ +softDeletes(); + }); + Schema::table('clubes', function (Blueprint $table) { + $table->softDeletes(); + }); + Schema::table('equipos', function (Blueprint $table) { + $table->softDeletes(); + }); + Schema::table('eventos', function (Blueprint $table) { + $table->softDeletes(); + }); + Schema::table('torneos', function (Blueprint $table) { + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('jugadores', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('clubes', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('equipos', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('eventos', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('torneos', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/migrations/2026_03_29_000001_create_notificaciones_table.php b/database/migrations/2026_03_29_000001_create_notificaciones_table.php new file mode 100644 index 0000000..e2f3c17 --- /dev/null +++ b/database/migrations/2026_03_29_000001_create_notificaciones_table.php @@ -0,0 +1,31 @@ +id(); + $table->enum('tipo_destinatario', ['jugador', 'aficionado'])->index(); + $table->string('id_destinatario'); // jugador.id_jugador o aficionado.id_aficionado + $table->enum('tipo', ['partido', 'resultado', 'sistema', 'seguimiento'])->default('sistema'); + $table->string('titulo'); + $table->text('mensaje'); + $table->string('url_accion')->nullable(); + $table->boolean('leida')->default(false); + $table->boolean('enviada_email')->default(false); + $table->timestamp('creada_en')->useCurrent(); + + $table->index(['tipo_destinatario', 'id_destinatario', 'leida']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notificaciones'); + } +}; diff --git a/database/migrations/2026_03_29_000002_create_equipo_seguimiento_table.php b/database/migrations/2026_03_29_000002_create_equipo_seguimiento_table.php new file mode 100644 index 0000000..5e49ea8 --- /dev/null +++ b/database/migrations/2026_03_29_000002_create_equipo_seguimiento_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedInteger('id_equipo'); + $table->enum('tipo_usuario', ['jugador', 'aficionado']); + $table->string('id_usuario'); // id_jugador (string) o id_aficionado (int as string) + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['id_equipo', 'tipo_usuario', 'id_usuario'], 'uq_seguimiento'); + $table->index(['tipo_usuario', 'id_usuario']); + }); + } + + public function down(): void + { + Schema::dropIfExists('equipo_seguimiento'); + } +}; diff --git a/database/migrations/2026_03_29_040005_add_phases_to_eventos_table.php b/database/migrations/2026_03_29_040005_add_phases_to_eventos_table.php new file mode 100644 index 0000000..2aa3e4e --- /dev/null +++ b/database/migrations/2026_03_29_040005_add_phases_to_eventos_table.php @@ -0,0 +1,29 @@ +integer('fase')->default(0)->after('id_torneo')->comment('0=Regular, 1=Cuartos, 2=Semis, 3=Final'); + $table->integer('numero_partido_bracket')->nullable()->after('fase'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('eventos', function (Blueprint $table) { + $table->dropColumn(['fase', 'numero_partido_bracket']); + }); + } +}; diff --git a/database/migrations/2026_03_29_091800_fix_qrcodes_and_eventos_schema.php b/database/migrations/2026_03_29_091800_fix_qrcodes_and_eventos_schema.php new file mode 100644 index 0000000..3738648 --- /dev/null +++ b/database/migrations/2026_03_29_091800_fix_qrcodes_and_eventos_schema.php @@ -0,0 +1,44 @@ + VARCHAR) + // Usamos DB::statement por compatibilidad con MariaDB/MySQL sin necesidad de dbal + DB::statement("ALTER TABLE qr_codes MODIFY tipo_qr VARCHAR(50) NOT NULL"); + + // 2. Corregir marcadores en eventos (0 -> NULL) + DB::statement("ALTER TABLE eventos MODIFY marcador_local INT NULL DEFAULT NULL"); + DB::statement("ALTER TABLE eventos MODIFY marcador_visitante INT NULL DEFAULT NULL"); + + // 3. Limpieza de datos: partidos con 0-0 que deberían ser Pendientes (NULL) + // Solo afectamos registros donde ambos son 0 para no romper resultados reales (aunque raros en básquet) + DB::table('eventos') + ->where('marcador_local', 0) + ->where('marcador_visitante', 0) + ->update([ + 'marcador_local' => null, + 'marcador_visitante' => null + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Volver a ENUM y Default 0 + DB::statement("ALTER TABLE qr_codes MODIFY tipo_qr ENUM('invitado', 'publico') NOT NULL"); + DB::statement("ALTER TABLE eventos MODIFY marcador_local INT NOT NULL DEFAULT 0"); + DB::statement("ALTER TABLE eventos MODIFY marcador_visitante INT NOT NULL DEFAULT 0"); + } +}; diff --git a/database/migrations/2026_03_31_112246_create_push_subscriptions_table.php b/database/migrations/2026_03_31_112246_create_push_subscriptions_table.php new file mode 100644 index 0000000..e892afb --- /dev/null +++ b/database/migrations/2026_03_31_112246_create_push_subscriptions_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('id_usuario'); // ID del jugador o aficionado + $table->string('tipo_usuario'); // 'jugador' o 'aficionado' + $table->text('endpoint'); // URL de suscripción del navegador + $table->string('p256dh'); // Clave pública del navegador + $table->string('auth'); // Token de autenticación del navegador + $table->timestamps(); + + $table->index(['id_usuario', 'tipo_usuario']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('push_subscriptions'); + } +}; diff --git a/database/migrations/2026_03_31_134500_add_categoria_and_id_torneo_to_noticias_table.php b/database/migrations/2026_03_31_134500_add_categoria_and_id_torneo_to_noticias_table.php new file mode 100644 index 0000000..beb8ec0 --- /dev/null +++ b/database/migrations/2026_03_31_134500_add_categoria_and_id_torneo_to_noticias_table.php @@ -0,0 +1,46 @@ +string('categoria', 50)->nullable()->after('contenido'); + } + + // Añadimos id_torneo si no existe + if (!Schema::hasColumn('noticias', 'id_torneo')) { + $table->unsignedBigInteger('id_torneo')->nullable()->after('categoria'); + // Intentamos añadir la clave foránea solo si la tabla torneos existe (por seguridad) + if (Schema::hasTable('torneos')) { + $table->foreign('id_torneo')->references('id')->on('torneos')->onDelete('set null'); + } + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('noticias', function (Blueprint $table) { + if (Schema::hasColumn('noticias', 'id_torneo')) { + $table->dropForeign(['id_torneo']); + $table->dropColumn('id_torneo'); + } + if (Schema::hasColumn('noticias', 'categoria')) { + $table->dropColumn('categoria'); + } + }); + } +}; diff --git a/database/migrations/2026_04_09_204613_create_agent_threads_table.php b/database/migrations/2026_04_09_204613_create_agent_threads_table.php new file mode 100644 index 0000000..008586e --- /dev/null +++ b/database/migrations/2026_04_09_204613_create_agent_threads_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('thread_id', 36)->unique(); + $table->integer('admin_id'); + $table->json('messages'); + $table->timestamps(); + $table->timestamp('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('agent_threads'); + } +}; diff --git a/database/migrations/2026_04_13_000000_add_es_seleccion_to_clubes_table.php b/database/migrations/2026_04_13_000000_add_es_seleccion_to_clubes_table.php new file mode 100644 index 0000000..41cf7ab --- /dev/null +++ b/database/migrations/2026_04_13_000000_add_es_seleccion_to_clubes_table.php @@ -0,0 +1,22 @@ +boolean('es_seleccion')->default(false)->after('nombre'); + }); + } + + public function down(): void + { + Schema::table('clubes', function (Blueprint $table) { + $table->dropColumn('es_seleccion'); + }); + } +}; diff --git a/database/migrations/2026_05_16_120000_add_timestamps_to_clubes_equipos_eventos.php b/database/migrations/2026_05_16_120000_add_timestamps_to_clubes_equipos_eventos.php new file mode 100644 index 0000000..6298886 --- /dev/null +++ b/database/migrations/2026_05_16_120000_add_timestamps_to_clubes_equipos_eventos.php @@ -0,0 +1,44 @@ +getTable(), 'created_at')) { + $table->timestamp('created_at')->nullable(); + } + if (!Schema::hasColumn($table->getTable(), 'updated_at')) { + $table->timestamp('updated_at')->nullable(); + } + }); + } + } + + public function down(): void + { + foreach (['clubes', 'equipos', 'eventos'] as $tabla) { + Schema::table($tabla, function (Blueprint $table) { + if (Schema::hasColumn($table->getTable(), 'updated_at')) { + $table->dropColumn('updated_at'); + } + if (Schema::hasColumn($table->getTable(), 'created_at')) { + $table->dropColumn('created_at'); + } + }); + } + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..6b901f8 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,25 @@ +create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/docs/superpowers/plans/2026-04-09-genius-agent.md b/docs/superpowers/plans/2026-04-09-genius-agent.md new file mode 100644 index 0000000..78eb978 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-genius-agent.md @@ -0,0 +1,1610 @@ +# OnAPB Genius Agent — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Agregar un agente de IA conversacional (Prism PHP + Gemini 1.5 Flash) con tools de function-calling para admins y asistente de navegación para usuarios públicos. + +**Architecture:** `GeniusAgentService` orquesta las llamadas a Prism con lista de tools dinámica (vacía para público, 5 tools para admin). Conversaciones de admin persisten en MySQL `agent_threads`; conversaciones públicas viven en sesión PHP. Sin streaming — respuesta JSON completa para compatibilidad con Hostinger. + +**Tech Stack:** PHP 8.2, Laravel 12, `prism-php/prism`, Google Gemini 1.5 Flash, MySQL, Bootstrap 5, vanilla JS. + +--- + +## Mapa de Archivos + +### Creados +| Archivo | Responsabilidad | +|---|---| +| `app/AI/Tools/ListarEquiposTool.php` | Invokable: consulta equipos con filtro torneo/grupo | +| `app/AI/Tools/ListarEventosTool.php` | Invokable: consulta partidos con filtro fecha/torneo | +| `app/AI/Tools/CrearPartidoTool.php` | Invokable: inserta un Evento en BD | +| `app/AI/Tools/CargarPuntajeTool.php` | Invokable: actualiza marcadores de un Evento | +| `app/AI/Tools/RedactarNoticiaTool.php` | Invokable: inserta una Noticia en BD | +| `app/AI/Prompts/SystemPromptAdmin.php` | Retorna string del system prompt para admins | +| `app/AI/Prompts/SystemPromptPublic.php` | Retorna string del system prompt público + manual RAG | +| `app/Services/GeniusAgentService.php` | Orquesta Prism: selecciona modo, ejecuta, persiste historial | +| `app/Http/Controllers/GeniusAgentController.php` | Recibe POST /agent/chat, valida, llama service, retorna JSON | +| `app/Models/AgentThread.php` | Eloquent model para historial admin | +| `app/Console/Commands/PurgeAgentThreads.php` | Artisan command: elimina threads expirados | +| `database/migrations/xxxx_create_agent_threads_table.php` | Tabla agent_threads | +| `resources/views/components/genius-chat.blade.php` | Widget chat bubble (Bootstrap + vanilla JS) | + +### Modificados +| Archivo | Cambio | +|---|---| +| `.env` / `.env.example` | Agregar `GEMINI_API_KEY` | +| `routes/web.php` | `POST /agent/chat` con throttle | +| `routes/console.php` | Schedule daily purge | +| `resources/views/layouts/app.blade.php` | Include del componente chat | + +### Tests +| Archivo | Qué prueba | +|---|---| +| `tests/Unit/AI/Tools/ListarEquiposToolTest.php` | Filtrado por torneo y grupo | +| `tests/Unit/AI/Tools/ListarEventosToolTest.php` | Filtrado por fecha e id_torneo | +| `tests/Unit/AI/Tools/CrearPartidoToolTest.php` | Inserta Evento correctamente | +| `tests/Unit/AI/Tools/CargarPuntajeToolTest.php` | Actualiza marcadores; falla gracefully si no existe | +| `tests/Unit/AI/Tools/RedactarNoticiaToolTest.php` | Inserta Noticia correctamente | +| `tests/Feature/GeniusAgentControllerTest.php` | HTTP layer: validación, throttle, error handling | + +--- + +## Tarea 1: Instalar Prism PHP y configurar Gemini + +**Archivos:** +- Modify: `composer.json` (via composer require) +- Modify: `.env` +- Modify: `.env.example` + +- [ ] **Paso 1: Instalar el paquete** + +```bash +composer require prism-php/prism +``` + +Resultado esperado: `prism-php/prism` aparece en `composer.json` bajo `require`. + +- [ ] **Paso 2: Publicar config de Prism** + +```bash +php artisan vendor:publish --provider="EchoLabs\Prism\PrismServiceProvider" --tag="prism-config" +``` + +Resultado esperado: archivo `config/prism.php` creado. + +- [ ] **Paso 3: Agregar API key en `.env`** + +Agregar al final de `.env`: +```env +GEMINI_API_KEY=tu_api_key_de_google_ai_studio +``` + +Agregar al final de `.env.example`: +```env +GEMINI_API_KEY= +``` + +- [ ] **Paso 4: Verificar que config/prism.php tiene la sección de Gemini** + +Abrir `config/prism.php` y confirmar que existe algo similar a: +```php +'gemini' => [ + 'api_key' => env('GEMINI_API_KEY', ''), +], +``` + +Si no existe, agregar ese bloque dentro de `'providers'`. + +- [ ] **Paso 5: Limpiar config cache** + +```bash +php artisan config:clear +``` + +- [ ] **Paso 6: Commit** + +```bash +git add composer.json composer.lock config/prism.php .env.example +git commit -m "chore: install prism-php/prism and configure Gemini provider" +``` + +--- + +## Tarea 2: Migration y Modelo AgentThread + +**Archivos:** +- Create: `database/migrations/xxxx_create_agent_threads_table.php` +- Create: `app/Models/AgentThread.php` + +- [ ] **Paso 1: Crear la migration** + +```bash +php artisan make:migration create_agent_threads_table +``` + +Abrir el archivo generado y reemplazar el contenido del método `up()`: + +```php +public function up(): void +{ + Schema::create('agent_threads', function (Blueprint $table) { + $table->id(); + $table->string('thread_id', 36)->unique(); + $table->integer('admin_id'); + $table->json('messages'); + $table->timestamps(); + $table->timestamp('expires_at'); + }); +} + +public function down(): void +{ + Schema::dropIfExists('agent_threads'); +} +``` + +- [ ] **Paso 2: Crear el modelo `app/Models/AgentThread.php`** + +```php + '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), + ]); + } +} +``` + +- [ ] **Paso 3: Ejecutar la migration** + +```bash +php artisan migrate +``` + +Resultado esperado: `Migrating: xxxx_create_agent_threads_table` → `Migrated`. + +- [ ] **Paso 4: Verificar el modelo con tinker** + +```bash +php artisan tinker --execute="App\Models\AgentThread::create(['thread_id'=>'test-uuid','admin_id'=>1,'messages'=>[],'expires_at'=>now()->addDays(30)]); echo App\Models\AgentThread::count();" +``` + +Resultado esperado: `1` + +- [ ] **Paso 5: Commit** + +```bash +git add database/migrations/ app/Models/AgentThread.php +git commit -m "feat: add agent_threads migration and AgentThread model" +``` + +--- + +## Tarea 3: Tools de solo lectura — ListarEquipos y ListarEventos + +**Archivos:** +- Create: `app/AI/Tools/ListarEquiposTool.php` +- Create: `app/AI/Tools/ListarEventosTool.php` +- Create: `tests/Unit/AI/Tools/ListarEquiposToolTest.php` +- Create: `tests/Unit/AI/Tools/ListarEventosToolTest.php` + +- [ ] **Paso 1: Crear directorios** + +```bash +mkdir -p app/AI/Tools app/AI/Prompts +mkdir -p tests/Unit/AI/Tools +``` + +- [ ] **Paso 2: Escribir el test de ListarEquiposTool** + +Crear `tests/Unit/AI/Tools/ListarEquiposToolTest.php`: + +```php + 1, 'nombre' => 'Club Test']); + Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']); + + $tool = new ListarEquiposTool(); + $result = json_decode($tool(), true); + + $this->assertCount(1, $result); + $this->assertEquals('Primera', $result[0]['categoria']); + } + + public function test_filtra_por_torneo_y_grupo(): void + { + Club::create(['id_club' => 1, 'nombre' => 'Club A']); + Club::create(['id_club' => 2, 'nombre' => 'Club B']); + $eq1 = Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']); + $eq2 = Equipo::create(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']); + $torneo = Torneo::create(['nombre' => 'Torneo 2025', 'fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]); + + \DB::table('torneo_equipo')->insert([ + ['id_torneo' => $torneo->id, 'id_equipo' => $eq1->id_equipo, 'grupo' => 'A'], + ['id_torneo' => $torneo->id, 'id_equipo' => $eq2->id_equipo, 'grupo' => 'B'], + ]); + + $tool = new ListarEquiposTool(); + $result = json_decode($tool(id_torneo: $torneo->id, grupo: 'A'), true); + + $this->assertCount(1, $result); + $this->assertEquals($eq1->id_equipo, $result[0]['id_equipo']); + } +} +``` + +- [ ] **Paso 3: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Unit/AI/Tools/ListarEquiposToolTest.php +``` + +Resultado esperado: `FAIL` — clase `ListarEquiposTool` no existe. + +- [ ] **Paso 4: Implementar `app/AI/Tools/ListarEquiposTool.php`** + +```php +join('torneo_equipo', 'equipos.id_equipo', '=', 'torneo_equipo.id_equipo') + ->where('torneo_equipo.id_torneo', $id_torneo); + + if ($grupo !== null) { + $query->where('torneo_equipo.grupo', $grupo); + } + + $query->select('equipos.id_equipo', 'equipos.categoria', 'equipos.division', 'equipos.id_club'); + } + + $equipos = $query->get()->map(fn($e) => [ + 'id_equipo' => $e->id_equipo, + 'categoria' => $e->categoria, + 'division' => $e->division, + 'club' => $e->club?->nombre, + ]); + + return json_encode($equipos); + } +} +``` + +- [ ] **Paso 5: Ejecutar el test (debe pasar)** + +```bash +php artisan test tests/Unit/AI/Tools/ListarEquiposToolTest.php +``` + +Resultado esperado: `PASS` — 2 tests, 0 fallos. + +- [ ] **Paso 6: Escribir el test de ListarEventosTool** + +Crear `tests/Unit/AI/Tools/ListarEventosToolTest.php`: + +```php + 1], ['nombre' => 'Club A']); + Club::firstOrCreate(['id_club' => 2], ['nombre' => 'Club B']); + $eq1 = Equipo::firstOrCreate(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']); + $eq2 = Equipo::firstOrCreate(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']); + $torneo = Torneo::firstOrCreate(['nombre' => 'Torneo Test'], ['fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]); + + return Evento::create(array_merge([ + 'id_evento' => (string) Str::uuid(), + 'id_equipo_local' => $eq1->id_equipo, + 'id_equipo_visitante' => $eq2->id_equipo, + 'fecha_evento' => '2025-06-15', + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'sede' => 'Estadio Test', + 'id_torneo' => $torneo->id, + 'precio' => 0, + 'fase' => 0, + ], $overrides)); + } + + public function test_retorna_eventos_sin_filtro(): void + { + $this->crearEvento(); + + $tool = new ListarEventosTool(); + $result = json_decode($tool(), true); + + $this->assertCount(1, $result); + $this->assertEquals('2025-06-15', $result[0]['fecha']); + } + + public function test_filtra_por_rango_de_fechas(): void + { + $this->crearEvento(['fecha_evento' => '2025-06-01', 'id_evento' => (string) Str::uuid()]); + $this->crearEvento(['fecha_evento' => '2025-07-01', 'id_evento' => (string) Str::uuid()]); + + $tool = new ListarEventosTool(); + $result = json_decode($tool(fecha_desde: '2025-07-01', fecha_hasta: '2025-07-31'), true); + + $this->assertCount(1, $result); + $this->assertEquals('2025-07-01', $result[0]['fecha']); + } +} +``` + +- [ ] **Paso 7: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Unit/AI/Tools/ListarEventosToolTest.php +``` + +Resultado esperado: `FAIL` — clase `ListarEventosTool` no existe. + +- [ ] **Paso 8: Implementar `app/AI/Tools/ListarEventosTool.php`** + +```php +whereNull('deleted_at'); + + if ($fecha_desde) { + $query->whereDate('fecha_evento', '>=', $fecha_desde); + } + if ($fecha_hasta) { + $query->whereDate('fecha_evento', '<=', $fecha_hasta); + } + if ($id_torneo) { + $query->where('id_torneo', $id_torneo); + } + + $eventos = $query->orderBy('fecha_evento')->get()->map(fn($e) => [ + 'id_evento' => $e->id_evento, + 'fecha' => $e->fecha_evento?->format('Y-m-d'), + 'hora' => $e->hora_inicio?->format('H:i'), + 'local' => $e->equipoLocal?->club?->nombre, + 'visitante' => $e->equipoVisitante?->club?->nombre, + 'marcador_local' => $e->marcador_local, + 'marcador_visitante' => $e->marcador_visitante, + 'sede' => $e->sede, + ]); + + return json_encode($eventos); + } +} +``` + +- [ ] **Paso 9: Ejecutar ambos tests** + +```bash +php artisan test tests/Unit/AI/Tools/ListarEquiposToolTest.php tests/Unit/AI/Tools/ListarEventosToolTest.php +``` + +Resultado esperado: `PASS` — 4 tests, 0 fallos. + +- [ ] **Paso 10: Commit** + +```bash +git add app/AI/Tools/ListarEquiposTool.php app/AI/Tools/ListarEventosTool.php \ + tests/Unit/AI/Tools/ListarEquiposToolTest.php tests/Unit/AI/Tools/ListarEventosToolTest.php +git commit -m "feat: add ListarEquipos and ListarEventos read-only tools" +``` + +--- + +## Tarea 4: Tool CrearPartido + +**Archivos:** +- Create: `app/AI/Tools/CrearPartidoTool.php` +- Create: `tests/Unit/AI/Tools/CrearPartidoToolTest.php` + +- [ ] **Paso 1: Escribir el test** + +Crear `tests/Unit/AI/Tools/CrearPartidoToolTest.php`: + +```php + 1, 'nombre' => 'Club A']); + Club::create(['id_club' => 2, 'nombre' => 'Club B']); + $eq1 = Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']); + $eq2 = Equipo::create(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']); + $torneo = Torneo::create(['nombre' => 'Torneo', 'fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]); + + $tool = new CrearPartidoTool(); + $result = json_decode($tool( + id_equipo_local: $eq1->id_equipo, + id_equipo_visitante: $eq2->id_equipo, + fecha_evento: '2025-08-10', + hora_inicio: '20:00', + hora_fin: '22:00', + sede: 'Estadio Municipal', + id_torneo: $torneo->id + ), true); + + $this->assertTrue($result['success']); + $this->assertDatabaseHas('eventos', [ + 'sede' => 'Estadio Municipal', + 'id_equipo_local' => $eq1->id_equipo, + ]); + } +} +``` + +- [ ] **Paso 2: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Unit/AI/Tools/CrearPartidoToolTest.php +``` + +Resultado esperado: `FAIL` + +- [ ] **Paso 3: Implementar `app/AI/Tools/CrearPartidoTool.php`** + +```php + (string) Str::uuid(), + 'id_equipo_local' => $id_equipo_local, + 'id_equipo_visitante' => $id_equipo_visitante, + 'fecha_evento' => $fecha_evento, + 'hora_inicio' => $hora_inicio . ':00', + 'hora_fin' => $hora_fin . ':00', + 'sede' => $sede, + 'id_torneo' => $id_torneo, + 'precio' => $precio ?? 0, + 'fase' => Evento::FASE_REGULAR, + ]); + + return json_encode([ + 'success' => true, + 'id_evento' => $evento->id_evento, + 'mensaje' => "Partido creado correctamente. ID: {$evento->id_evento}", + ]); + } +} +``` + +- [ ] **Paso 4: Ejecutar el test (debe pasar)** + +```bash +php artisan test tests/Unit/AI/Tools/CrearPartidoToolTest.php +``` + +Resultado esperado: `PASS` + +- [ ] **Paso 5: Commit** + +```bash +git add app/AI/Tools/CrearPartidoTool.php tests/Unit/AI/Tools/CrearPartidoToolTest.php +git commit -m "feat: add CrearPartidoTool" +``` + +--- + +## Tarea 5: Tool CargarPuntaje + +**Archivos:** +- Create: `app/AI/Tools/CargarPuntajeTool.php` +- Create: `tests/Unit/AI/Tools/CargarPuntajeToolTest.php` + +- [ ] **Paso 1: Escribir el test** + +Crear `tests/Unit/AI/Tools/CargarPuntajeToolTest.php`: + +```php + 1, 'nombre' => 'Club A']); + Club::create(['id_club' => 2, 'nombre' => 'Club B']); + $eq1 = Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']); + $eq2 = Equipo::create(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']); + $torneo = Torneo::create(['nombre' => 'Torneo', 'fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]); + + return Evento::create([ + 'id_evento' => (string) Str::uuid(), + 'id_equipo_local' => $eq1->id_equipo, + 'id_equipo_visitante' => $eq2->id_equipo, + 'fecha_evento' => '2025-08-10', + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'sede' => 'Estadio', + 'id_torneo' => $torneo->id, + 'precio' => 0, + 'fase' => 0, + ]); + } + + public function test_actualiza_puntaje_correctamente(): void + { + $evento = $this->crearEvento(); + + $tool = new CargarPuntajeTool(); + $result = json_decode($tool( + id_evento: $evento->id_evento, + marcador_local: 85, + marcador_visitante: 72 + ), true); + + $this->assertTrue($result['success']); + $this->assertDatabaseHas('eventos', [ + 'id_evento' => $evento->id_evento, + 'marcador_local' => 85, + 'marcador_visitante' => 72, + ]); + } + + public function test_retorna_error_si_evento_no_existe(): void + { + $tool = new CargarPuntajeTool(); + $result = json_decode($tool( + id_evento: 'uuid-inexistente', + marcador_local: 10, + marcador_visitante: 20 + ), true); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('no encontrado', $result['error']); + } +} +``` + +- [ ] **Paso 2: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Unit/AI/Tools/CargarPuntajeToolTest.php +``` + +Resultado esperado: `FAIL` + +- [ ] **Paso 3: Implementar `app/AI/Tools/CargarPuntajeTool.php`** + +```php + false, + 'error' => "Evento '{$id_evento}' no encontrado.", + ]); + } + + $evento->update([ + 'marcador_local' => $marcador_local, + 'marcador_visitante' => $marcador_visitante, + ]); + + return json_encode([ + 'success' => true, + 'mensaje' => "Puntaje cargado: {$marcador_local} - {$marcador_visitante}", + ]); + } +} +``` + +- [ ] **Paso 4: Ejecutar el test (debe pasar)** + +```bash +php artisan test tests/Unit/AI/Tools/CargarPuntajeToolTest.php +``` + +Resultado esperado: `PASS` — 2 tests. + +- [ ] **Paso 5: Commit** + +```bash +git add app/AI/Tools/CargarPuntajeTool.php tests/Unit/AI/Tools/CargarPuntajeToolTest.php +git commit -m "feat: add CargarPuntajeTool" +``` + +--- + +## Tarea 6: Tool RedactarNoticia + +**Archivos:** +- Create: `app/AI/Tools/RedactarNoticiaTool.php` +- Create: `tests/Unit/AI/Tools/RedactarNoticiaToolTest.php` + +- [ ] **Paso 1: Escribir el test** + +Crear `tests/Unit/AI/Tools/RedactarNoticiaToolTest.php`: + +```php +assertTrue($result['success']); + $this->assertDatabaseHas('noticias', ['titulo' => 'Gran partido en la final']); + } + + public function test_crea_noticia_con_torneo_y_categoria(): void + { + $tool = new RedactarNoticiaTool(); + $result = json_decode($tool( + titulo: 'Resumen jornada', + contenido: 'Resumen de la jornada 5.', + id_torneo: 1, + categoria: 'resultados' + ), true); + + $this->assertTrue($result['success']); + $this->assertDatabaseHas('noticias', [ + 'titulo' => 'Resumen jornada', + 'categoria' => 'resultados', + ]); + } +} +``` + +- [ ] **Paso 2: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Unit/AI/Tools/RedactarNoticiaToolTest.php +``` + +Resultado esperado: `FAIL` + +- [ ] **Paso 3: Implementar `app/AI/Tools/RedactarNoticiaTool.php`** + +```php + $titulo, + 'contenido' => $contenido, + 'fecha' => now(), + 'id_torneo' => $id_torneo, + 'categoria' => $categoria, + ]); + + return json_encode([ + 'success' => true, + 'id' => $noticia->id, + 'mensaje' => "Noticia creada: {$noticia->titulo}", + ]); + } +} +``` + +- [ ] **Paso 4: Ejecutar el test (debe pasar)** + +```bash +php artisan test tests/Unit/AI/Tools/RedactarNoticiaToolTest.php +``` + +Resultado esperado: `PASS` — 2 tests. + +- [ ] **Paso 5: Ejecutar todos los tests de tools** + +```bash +php artisan test tests/Unit/AI/Tools/ +``` + +Resultado esperado: `PASS` — 9 tests, 0 fallos. + +- [ ] **Paso 6: Commit** + +```bash +git add app/AI/Tools/RedactarNoticiaTool.php tests/Unit/AI/Tools/RedactarNoticiaToolTest.php +git commit -m "feat: add RedactarNoticiaTool — all 5 tools complete" +``` + +--- + +## Tarea 7: System Prompts + +**Archivos:** +- Create: `app/AI/Prompts/SystemPromptAdmin.php` +- Create: `app/AI/Prompts/SystemPromptPublic.php` + +- [ ] **Paso 1: Crear `app/AI/Prompts/SystemPromptAdmin.php`** + +```php +loadManual(); + + return <<chatAdmin($message, $threadId); + } + + return $this->chatPublic($message); + } + + private function chatPublic(string $message): array + { + $history = session('agent_messages', []); + $messages = $this->hydrate($history); + $messages[] = new UserMessage($message); + + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withSystemPrompt((new SystemPromptPublic())->build()) + ->withMessages($messages) + ->generate(); + + $reply = $response->text; + + $history[] = ['role' => 'user', 'content' => $message]; + $history[] = ['role' => 'assistant', 'content' => $reply]; + session(['agent_messages' => $history]); + + return ['reply' => $reply]; + } + + private function chatAdmin(string $message, ?string $threadId): array + { + $adminId = (int) session('admin_id', 0); + $thread = AgentThread::findOrCreateForAdmin($threadId, $adminId); + + $messages = $this->hydrate($thread->messages ?? []); + $messages[] = new UserMessage($message); + + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withSystemPrompt((new SystemPromptAdmin())->build()) + ->withMessages($messages) + ->withTools($this->buildAdminTools()) + ->generate(); + + $reply = $response->text; + + $stored = $thread->messages ?? []; + $stored[] = ['role' => 'user', 'content' => $message]; + $stored[] = ['role' => 'assistant', 'content' => $reply]; + $thread->messages = $stored; + $thread->save(); + + return ['reply' => $reply, 'thread_id' => $thread->thread_id]; + } + + private function hydrate(array $stored): array + { + return collect($stored)->map(fn($m) => $m['role'] === 'user' + ? new UserMessage($m['content']) + : new AssistantMessage($m['content']) + )->all(); + } + + private function buildAdminTools(): array + { + return [ + Tool::as('listar_equipos') + ->for('Lista los equipos. Filtrá por id_torneo y/o grupo para obtener los IDs correctos antes de crear partidos.') + ->withNumberParameter('id_torneo', 'ID del torneo (opcional)', false) + ->withStringParameter('grupo', 'Nombre del grupo dentro del torneo (opcional)', false) + ->using(new ListarEquiposTool()), + + Tool::as('listar_eventos') + ->for('Lista los partidos. Filtrá por rango de fechas (Y-m-d) o id_torneo para obtener los IDs antes de cargar puntajes.') + ->withStringParameter('fecha_desde', 'Fecha desde en formato Y-m-d (opcional)', false) + ->withStringParameter('fecha_hasta', 'Fecha hasta en formato Y-m-d (opcional)', false) + ->withNumberParameter('id_torneo', 'ID del torneo (opcional)', false) + ->using(new ListarEventosTool()), + + Tool::as('crear_partido') + ->for('Crea un nuevo partido en el sistema. Podés llamar esta tool múltiples veces para crear varios partidos.') + ->withNumberParameter('id_equipo_local', 'ID del equipo local') + ->withNumberParameter('id_equipo_visitante', 'ID del equipo visitante') + ->withStringParameter('fecha_evento', 'Fecha del partido en formato Y-m-d') + ->withStringParameter('hora_inicio', 'Hora de inicio en formato H:i (ej: 20:00)') + ->withStringParameter('hora_fin', 'Hora de fin en formato H:i (ej: 22:00)') + ->withStringParameter('sede', 'Lugar donde se juega el partido') + ->withNumberParameter('id_torneo', 'ID del torneo al que pertenece el partido') + ->withNumberParameter('precio', 'Precio de la entrada en pesos (0 si es gratis)', false) + ->using(new CrearPartidoTool()), + + Tool::as('cargar_puntaje') + ->for('Carga o actualiza el puntaje de un partido existente. Podés llamar esta tool múltiples veces para cargar puntajes de varios partidos.') + ->withStringParameter('id_evento', 'ID del evento (UUID — usá listar_eventos para obtenerlo)') + ->withNumberParameter('marcador_local', 'Puntos del equipo local') + ->withNumberParameter('marcador_visitante', 'Puntos del equipo visitante') + ->using(new CargarPuntajeTool()), + + Tool::as('redactar_noticia') + ->for('Crea una noticia en el sistema. El contenido puede ser HTML o texto plano.') + ->withStringParameter('titulo', 'Título de la noticia') + ->withStringParameter('contenido', 'Contenido completo de la noticia') + ->withNumberParameter('id_torneo', 'ID del torneo relacionado (opcional)', false) + ->withStringParameter('categoria', 'Categoría de la noticia (opcional)', false) + ->using(new RedactarNoticiaTool()), + ]; + } +} +``` + +- [ ] **Paso 2: Verificar que no hay errores de sintaxis** + +```bash +php artisan tinker --execute="new App\Services\GeniusAgentService(); echo 'OK';" +``` + +Resultado esperado: `OK` + +- [ ] **Paso 3: Commit** + +```bash +git add app/Services/GeniusAgentService.php +git commit -m "feat: add GeniusAgentService with public/admin chat modes" +``` + +--- + +## Tarea 9: GeniusAgentController y Route + +**Archivos:** +- Create: `app/Http/Controllers/GeniusAgentController.php` +- Create: `tests/Feature/GeniusAgentControllerTest.php` +- Modify: `routes/web.php` + +- [ ] **Paso 1: Escribir el test del controller** + +Crear `tests/Feature/GeniusAgentControllerTest.php`: + +```php +postJson('/agent/chat', ['message' => '']); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['message']); + } + + public function test_rechaza_mensaje_demasiado_largo(): void + { + $response = $this->postJson('/agent/chat', ['message' => str_repeat('a', 1001)]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['message']); + } + + public function test_usuario_publico_recibe_respuesta(): void + { + $this->mock(GeniusAgentService::class, function ($mock) { + $mock->shouldReceive('chat') + ->once() + ->with('Hola', false, null) + ->andReturn(['reply' => 'Hola desde el agente']); + }); + + $response = $this->postJson('/agent/chat', ['message' => 'Hola']); + + $response->assertStatus(200) + ->assertJson(['reply' => 'Hola desde el agente']); + } + + public function test_admin_recibe_respuesta_con_thread_id(): void + { + $this->mock(GeniusAgentService::class, function ($mock) { + $mock->shouldReceive('chat') + ->once() + ->andReturn(['reply' => 'Listo', 'thread_id' => 'test-uuid']); + }); + + $response = $this->withSession(['admin_logged_in' => true]) + ->postJson('/agent/chat', ['message' => 'Crear partido']); + + $response->assertStatus(200) + ->assertJsonStructure(['reply', 'thread_id']); + } + + public function test_retorna_error_generico_si_el_servicio_falla(): void + { + $this->mock(GeniusAgentService::class, function ($mock) { + $mock->shouldReceive('chat') + ->once() + ->andThrow(new \Exception('API timeout')); + }); + + $response = $this->postJson('/agent/chat', ['message' => 'Hola']); + + $response->assertStatus(500) + ->assertJson(['error' => 'El agente no responde, reintentá en un momento.']); + } +} +``` + +- [ ] **Paso 2: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Feature/GeniusAgentControllerTest.php +``` + +Resultado esperado: `FAIL` — ruta no existe. + +- [ ] **Paso 3: Crear el controller `app/Http/Controllers/GeniusAgentController.php`** + +```php +validate([ + 'message' => 'required|string|max:1000', + 'thread_id' => 'nullable|string|max:36', + ]); + + $isAdmin = (bool) session('admin_logged_in'); + + try { + $result = $this->service->chat( + $data['message'], + $isAdmin, + $data['thread_id'] ?? null + ); + + return response()->json($result); + } catch (\Throwable $e) { + \Log::error('GeniusAgent error: ' . $e->getMessage()); + + return response()->json( + ['error' => 'El agente no responde, reintentá en un momento.'], + 500 + ); + } + } +} +``` + +- [ ] **Paso 4: Agregar la ruta en `routes/web.php`** + +Al final de las importaciones en `routes/web.php`, agregar: +```php +use App\Http\Controllers\GeniusAgentController; +``` + +Al final del archivo, agregar: +```php +Route::post('/agent/chat', [GeniusAgentController::class, 'chat']) + ->name('agent.chat') + ->middleware('throttle:20,1'); +``` + +- [ ] **Paso 5: Ejecutar el test (debe pasar)** + +```bash +php artisan test tests/Feature/GeniusAgentControllerTest.php +``` + +Resultado esperado: `PASS` — 4 tests. + +- [ ] **Paso 6: Commit** + +```bash +git add app/Http/Controllers/GeniusAgentController.php \ + tests/Feature/GeniusAgentControllerTest.php \ + routes/web.php +git commit -m "feat: add GeniusAgentController and POST /agent/chat route" +``` + +--- + +## Tarea 10: Comando PurgeAgentThreads + +**Archivos:** +- Create: `app/Console/Commands/PurgeAgentThreads.php` +- Modify: `routes/console.php` + +- [ ] **Paso 1: Crear el command** + +```bash +php artisan make:command PurgeAgentThreads +``` + +Abrir `app/Console/Commands/PurgeAgentThreads.php` y reemplazar con: + +```php +delete(); + $this->info("Eliminados {$count} threads expirados."); + + return self::SUCCESS; + } +} +``` + +- [ ] **Paso 2: Registrar en el schedule (`routes/console.php`)** + +Abrir `routes/console.php` y agregar al final: + +```php +use Illuminate\Support\Facades\Schedule; + +Schedule::command('agent:purge-threads')->daily(); +``` + +Si `Schedule::` ya está importado, omitir el `use`. + +- [ ] **Paso 3: Verificar que el command funciona** + +```bash +php artisan agent:purge-threads +``` + +Resultado esperado: `Eliminados 0 threads expirados.` + +- [ ] **Paso 4: Commit** + +```bash +git add app/Console/Commands/PurgeAgentThreads.php routes/console.php +git commit -m "feat: add agent:purge-threads command with daily schedule" +``` + +--- + +## Tarea 11: Chat Bubble — Frontend y Wiring + +**Archivos:** +- Create: `resources/views/components/genius-chat.blade.php` +- Modify: `resources/views/layouts/app.blade.php` + +- [ ] **Paso 1: Crear `resources/views/components/genius-chat.blade.php`** + +```html +
+ + {{-- Botón flotante --}} + + + {{-- Panel del chat --}} + +
+ + +``` + +- [ ] **Paso 2: Incluir el componente en `resources/views/layouts/app.blade.php`** + +Buscar la línea `` 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 0000000..76ffe70 Binary files /dev/null and b/misc/OnAPB - Manual de Usuario.pdf differ diff --git a/misc/analisis_requerimientos.md b/misc/analisis_requerimientos.md new file mode 100644 index 0000000..0129b6b --- /dev/null +++ b/misc/analisis_requerimientos.md @@ -0,0 +1,42 @@ +# Análisis de Requisitos del Sistema (OnAPB) + +En base a la imagen `requerimientos.jpeg` y la inspección del código fuente actual, aquí está el detalle de qué puntos cumple el sistema, cuáles faltan por desarrollar, y cuáles dependen exclusivamente de la presentación oral. + +## ✅ Requisitos Cumplidos (Implementados en Software) + +| Punto | Descripción | Estado en el Sistema | +| :---: | :--- | :--- | +| **1** | **Innovación** | **Cumple.** La implementación de un sistema de códigos QR digitales para el acceso a eventos y manejo de "Pases" de jugadores digitaliza procesos típicamente manuales o en papel. | +| **2** | **Investigación durante el desarrollo** | **Cumple.** Se evidencia el uso del framework Laravel 11, sistema de autenticación custom (aficionados/jugadores/admin), y estructuración de base de datos relacional. | +| **6** | **Justificación de Ingeniería de Software** | **Cumple.** El sistema utiliza un patrón arquitectónico claro (MVC provisto por Laravel), ORM (Eloquent), migraciones de base de datos y un enrutamiento ordenado. | +| **7** | **Conocimiento del negocio** | **Cumple.** El software modela perfectamente la lógica de una asociación de básquet: Clubes, Equipos, Jugadores, pases interclubes, categorías por edad y gestión de aforos de QRs por partido. | +| **9** | **Completitud de software** | **Parcialmente Cumplido.** El sistema tiene paneles de usuario y admin muy completos (fases 1 a 5 de la Hoja de Ruta). Sin embargo, la **Fase 6 (Pagos con Banco Macro)** está pendiente. | + +--- + +## ❌ Requisitos Faltantes (Deuda Técnica a Resolver) + +| Punto | Descripción | Lo que falta hacer | +| :---: | :--- | :--- | +| **8** | **Pruebas y errores, end-to-end** | **FALTA.** La carpeta `tests/` está vacía (solo tiene el test de ejemplo de Laravel). No hay pruebas unitarias (PHPUnit) ni pruebas de integración o E2E (Laravel Dusk) configuradas ni ejecutadas. | +| **10** | **Recuperación ante fallos/desastres** | **FALTA.** No hay un sistema de copias de seguridad de la base de datos (ej. Laravel Backup). Además, los modelos actuales **no usan SoftDeletes**, por lo que si un admin elimina un equipo, jugador o evento, los datos **se pierden permanentemente** (Hard Delete) y pueden romper relaciones. | +| **13** | **Documentación Técnica y de Usuario** | **FALTA.** El archivo `README.md` del repositorio es el que viene por defecto con Laravel original en inglés. La `HOJA-DE-RUTA.md` es solo un checklist. Se necesita redactar un manual de usuario (ej: cómo usar el panel admin) y un manual técnico (cómo instalar el proyecto, variables de entorno, esquema de BD). | + +--- + +## 🗣️ Requisitos que NO dependen del software (Evaluación Oral/Grupal) + +Estos puntos no se programan, sino que se evaluarán durante la defensa del proyecto: + +* **3 - Tiempo/dedicación en finalizar.** (Se demuestra presentando el volumen de trabajo hecho, que es bastante amplio). +* **4 - Exposición oral.** +* **5 - Evaluación de dinámica grupal.** +* **11 - Revisiones de procesos y sugerencias por parte de los profesionales.** (Conclusiones del grupo). +* **12 - Pruebas y errores durante la exposición (datos reales).** (Asegúrense de tener la base de datos poblada con clubes, jugadores y eventos reales o realistas el día de la presentación). + +--- + +### 🚀 Siguientes pasos recomendados para aprobar: +1. Reemplazar el `README.md` por documentación real técnica y de usuario. +2. Añadir `SoftDeletes` a las tablas críticas (`jugadores`, `clubes`, `equipos`, `eventos`) para cumplir el Punto 10. +3. Escribir al menos 3 o 4 Tests básicos (Feature tests) simulando el login y la emisión de QRs para cubrir el Punto 8. diff --git a/misc/onapb-security-hardening.md b/misc/onapb-security-hardening.md new file mode 100644 index 0000000..2cfc70b --- /dev/null +++ b/misc/onapb-security-hardening.md @@ -0,0 +1,334 @@ +# Blueprint de Seguridad — onapb.com +**Generado por:** The Architect +**Fecha:** 2026-04-01 +**Para ejecutar con:** Claude Code en el repositorio del sistema + +--- + +## Contexto + +Este documento es una instrucción completa y autónoma para que Claude Code implemente todas las mejoras de seguridad detectadas en el sitio **onapb.com**. + +- **Stack:** Laravel (PHP 8.4.11) + LiteSpeed + Hostinger +- **Framework detectado:** Laravel (confirmado por cookies `laravel-session` y `XSRF-TOKEN`) +- **Dev responsable:** único desarrollador con acceso completo al repositorio + +Ejecutá cada paso en orden. No saltes pasos. Al final, hacé deploy a producción. + +--- + +## CLAUDE.md para este proyecto + +``` +Sos el implementador de seguridad del sitio onapb.com. +Tu tarea es implementar los cambios de seguridad descritos en este archivo en orden. +El stack es Laravel. No rompas funcionalidad existente. +Al terminar cada paso, confirmá que los tests pasen antes de avanzar. +``` + +--- + +## Orden de implementación + +### PASO 1 — Middleware de Security Headers + +**Problema:** Faltan 5 headers de seguridad críticos: +- `Strict-Transport-Security` (HSTS) +- `X-Frame-Options` +- `X-Content-Type-Options` +- `X-XSS-Protection` +- `Content-Security-Policy` (actualmente solo tiene `upgrade-insecure-requests`) + +**Qué hacer:** + +1. Crear el archivo `app/Http/Middleware/SecurityHeaders.php`: + +```php +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'; " . + "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'; " . + "frame-ancestors 'self';" + ); + + return $response; + } +} +``` + +2. Registrar el middleware en `app/Http/Kernel.php`, dentro de `$middlewareGroups['web']`, al final del array: + +```php +\App\Http\Middleware\SecurityHeaders::class, +``` + +3. Verificar: hacer `php artisan route:list` y luego una request curl local para confirmar que los headers aparecen. + +--- + +### PASO 2 — Ocultar versión de PHP y stack + +**Problema:** El header `X-Powered-By: PHP/8.4.11` y `Server: LiteSpeed` revelan el stack exacto. + +**Qué hacer:** + +1. En el archivo `public/.htaccess`, agregar al inicio (después de `` o antes): + +```apache +# Ocultar headers de servidor + + Header unset X-Powered-By + Header always unset X-Powered-By + Header unset Server + +``` + +2. Si tenés acceso a `php.ini` (en Hostinger hPanel → PHP → php.ini personalizado): + +```ini +expose_php = Off +``` + +3. Si usás un archivo `php.ini` en la raíz del proyecto, agregar esa línea ahí también. + +--- + +### PASO 3 — Forzar HTTPS y HSTS en .htaccess + +**Problema:** El sitio no sirve headers HSTS. El HSTS del Paso 1 solo actúa cuando ya estás en HTTPS, pero si alguien entra por HTTP no hay redirect forzado a nivel servidor. + +**Qué hacer:** + +En `public/.htaccess`, asegurar que exista este bloque (Laravel suele tenerlo pero verificar): + +```apache + + RewriteEngine On + + # Forzar HTTPS + RewriteCond %{HTTPS} off + RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + + # Resto de reglas de Laravel... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + +``` + +--- + +### PASO 4 — Proteger archivo .env y rutas sensibles + +**Problema:** Laravel ya protege `.env` por defecto, pero verificar que las rutas de storage no sean accesibles. + +**Qué hacer:** + +En `public/.htaccess`, agregar: + +```apache +# Bloquear acceso a archivos sensibles + + Order allow,deny + Deny from all + + +# Bloquear acceso directo a /storage si está symlinkado + + RewriteRule ^storage/app/(.*)$ - [F,L] + +``` + +--- + +### PASO 5 — Mejorar robots.txt + +**Problema:** El `robots.txt` actual tiene `Disallow: ""` (vacío), lo que permite que todos los bots indexen todo, incluidas rutas como `/recuperar` y rutas de admin. + +**Qué hacer:** + +Reemplazar el contenido de `public/robots.txt` (o crearlo si no existe): + +``` +User-agent: * +Disallow: /admin +Disallow: /recuperar +Disallow: /api/ +Disallow: /storage/ +Disallow: /vendor/ +Allow: / + +Sitemap: https://onapb.com/sitemap.xml +``` + +--- + +### PASO 6 — Agregar security.txt + +**Problema:** No existe `/well-known/security.txt`. Es una buena práctica de divulgación responsable. + +**Qué hacer:** + +1. Crear el directorio `public/.well-known/` +2. Crear el archivo `public/.well-known/security.txt`: + +``` +Contact: mailto:admin@onapb.com +Expires: 2027-04-01T00:00:00Z +Preferred-Languages: es, en +Canonical: https://onapb.com/.well-known/security.txt +``` + +(Reemplazar `admin@onapb.com` con el email real del responsable de seguridad.) + +--- + +### PASO 7 — Rate limiting en rutas críticas + +**Problema:** No se detectó rate limiting explícito. Rutas como `/recuperar` (recuperación de contraseña) son vectores de ataque por fuerza bruta y enumeración de usuarios. + +**Qué hacer:** + +En `routes/web.php` o en el middleware de rutas, asegurar rate limiting en rutas sensibles: + +```php +Route::middleware(['throttle:5,1'])->group(function () { + Route::post('/recuperar', [PasswordController::class, 'sendResetLink']); + Route::post('/login', [AuthController::class, 'login']); +}); +``` + +El `5,1` significa 5 intentos por minuto. Ajustar según la lógica del negocio. + +Si el proyecto usa Laravel Breeze o Fortify, ya tiene esto incorporado — verificar que esté habilitado en `config/fortify.php`: + +```php +'limiters' => [ + 'login' => 'login', +], +``` + +--- + +### PASO 8 — Configurar Content Security Policy estricta (opcional pero recomendado) + +**Problema:** La CSP del Paso 1 usa `'unsafe-inline'` y `'unsafe-eval'` como fallback seguro. Si el proyecto no necesita eval, endurecerla. + +**Qué hacer:** + +Una vez que el sitio esté corriendo con el middleware del Paso 1, revisar la consola del navegador por errores de CSP. Si no hay errores de scripts inline, cambiar en `SecurityHeaders.php`: + +```php +"script-src 'self';" // quitar unsafe-inline y unsafe-eval +``` + +Esto requiere que todos los scripts estén en archivos externos, no inline. + +--- + +## Pasos que requieren acción MANUAL (fuera del repositorio) + +Estos cambios no se hacen en el código — son configuraciones del servidor/DNS. Documentarlos para hacerlos en paralelo: + +### A — Cerrar puerto 3306 (CRÍTICO — hacer YA) +- En Hostinger hPanel → Seguridad → Firewall +- Bloquear acceso externo al puerto 3306 (MySQL) +- Solo debe aceptar conexiones desde `127.0.0.1` (localhost) +- Verificar en `.env`: `DB_HOST=127.0.0.1` + +### B — Deshabilitar FTP (puerto 21) +- En Hostinger hPanel → Archivos → Administrador FTP +- Deshabilitar FTP o cambiar a SFTP (puerto 22) + +### C — Migrar DNS a Cloudflare (recomendado para WAF) +- Crear cuenta en cloudflare.com +- Agregar el dominio `onapb.com` +- Cambiar los nameservers en Registrar.eu (registrar actual) a los de Cloudflare +- Habilitar WAF en plan gratuito +- Habilitar DNSSEC en Cloudflare (un click) +- Habilitar proxy (nube naranja) para ocultar IP real + +### D — Configurar DMARC + endurecer SPF +En el panel DNS (Hostinger o Cloudflare): + +**Cambiar SPF existente** (`~all` → `-all`): +``` +v=spf1 include:_spf.mail.hostinger.com -all +``` + +**Agregar registro DMARC** (nuevo TXT en `_dmarc.onapb.com`): +``` +v=DMARC1; p=quarantine; rua=mailto:admin@onapb.com; fo=1 +``` + +### E — Verificar DKIM +En Hostinger hPanel → Email → Configuración de dominio → habilitar DKIM si no está activo. + +--- + +## Orden de deploy + +Una vez implementados los pasos de código (1 al 8): + +1. Correr tests: `php artisan test` +2. Verificar que la app levanta: `php artisan serve` + revisar consola del browser +3. Hacer commit con mensaje: `security: add security headers middleware and hardening` +4. Push a rama de staging si existe, sino directo a `main` +5. Deploy a producción (según el proceso actual del proyecto) +6. Verificar headers en producción: usar [securityheaders.com](https://securityheaders.com) con `onapb.com` +7. Verificar que el sitio funciona 100% antes de cerrar puertos (paso manual A) + +--- + +## Verificación post-deploy + +Correr estos checks para confirmar que todo quedó bien: + +| Check | Herramienta | URL | +|-------|-------------|-----| +| Headers HTTP | Security Headers | securityheaders.com | +| SSL/TLS | SSL Labs | ssllabs.com/ssltest | +| HSTS | hstspreload.org | hstspreload.org | +| Reputación IP | VirusTotal | virustotal.com | +| Puertos | Shodan | shodan.io | + +--- + +## Resultado esperado + +Al completar todos los pasos, el sitio debe: +- Obtener calificación **A** en securityheaders.com (actualmente F) +- No exponer versión de PHP ni stack en headers +- Tener HSTS activo con preload +- No tener puertos de base de datos expuestos a internet +- Tener protección WAF básica via Cloudflare +- Tener autenticación de email (SPF + DKIM + DMARC) diff --git a/misc/requerimientos.jpeg b/misc/requerimientos.jpeg new file mode 100644 index 0000000..2f44deb Binary files /dev/null and b/misc/requerimientos.jpeg differ