Backup y detalles minimos

This commit is contained in:
2026-05-27 15:59:44 -03:00
parent 2302d3f550
commit b2e8021c02
10 changed files with 722 additions and 94 deletions
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Illuminate\Support\Facades\File;
use ZipArchive;
class BackupDatabase extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'db:backup';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Crea una copia de seguridad comprimida de la base de datos MySQL y elimina respaldos antiguos.';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Iniciando copia de seguridad de la base de datos...');
$dbConfig = config('database.connections.mysql');
if (!$dbConfig) {
$this->error('No se pudo cargar la configuración de la base de datos MySQL.');
return 1;
}
$host = $dbConfig['host'] ?? '127.0.0.1';
$port = $dbConfig['port'] ?? '3306';
$database = $dbConfig['database'] ?? 'lauck';
$username = $dbConfig['username'] ?? 'root';
$password = $dbConfig['password'] ?? '';
// Asegurarse de que el directorio de respaldos exista
$backupDir = storage_path('app/backups');
if (!File::exists($backupDir)) {
File::makeDirectory($backupDir, 0755, true);
}
// Nombre de los archivos
$timestamp = now()->format('Y-m-d_H-i-s');
$sqlFile = "backup-{$database}-{$timestamp}.sql";
$sqlPath = $backupDir . '/' . $sqlFile;
$zipFile = "backup-{$database}-{$timestamp}.zip";
$zipPath = $backupDir . '/' . $zipFile;
// Buscar el ejecutable mysqldump
$mysqldumpPath = 'mysqldump';
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
// En Windows/XAMPP comúnmente está en C:\xampp\mysql\bin\mysqldump.exe
$xamppPath = 'C:\\xampp\\mysql\\bin\\mysqldump.exe';
if (File::exists($xamppPath)) {
$mysqldumpPath = $xamppPath;
}
}
// Ejecutar mysqldump con Process
// Usamos variables de entorno para la contraseña por seguridad
$command = [
$mysqldumpPath,
"--host={$host}",
"--port={$port}",
"--user={$username}",
"--no-tablespaces",
$database
];
// Inherit SystemRoot and PATH from the system environment to prevent Winsock 10106 errors on Windows
$env = [
'SystemRoot' => getenv('SystemRoot') ?: 'C:\\Windows',
'windir' => getenv('windir') ?: 'C:\\Windows',
'PATH' => getenv('PATH'),
];
if ($password !== '') {
$env['MYSQL_PWD'] = $password;
}
$this->info("Ejecutando volcado a archivo temporal SQL...");
$process = new Process($command, null, $env);
$process->setTimeout(300); // 5 minutos máximo
try {
$process->run();
if (!$process->isSuccessful()) {
throw new \Exception(trim($process->getErrorOutput()) ?: 'Error desconocido al ejecutar mysqldump.');
}
// Guardar salida en el archivo SQL
File::put($sqlPath, $process->getOutput());
} catch (\Exception $e) {
$this->error('Error durante el volcado de la base de datos: ' . $e->getMessage());
// Limpieza si quedó el archivo a medio hacer
if (File::exists($sqlPath)) {
File::delete($sqlPath);
}
return 1;
}
// Comprimir el archivo SQL a ZIP
$this->info('Comprimiendo respaldo...');
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE) === true) {
$zip->addFile($sqlPath, $sqlFile);
$zip->close();
// Eliminar el archivo SQL temporal
File::delete($sqlPath);
$this->info("Copia de seguridad guardada con éxito en: {$zipFile}");
} else {
$this->error('No se pudo crear el archivo ZIP.');
return 1;
}
// Retención de respaldos (limpieza de archivos antiguos)
$this->cleanupOldBackups($backupDir);
return 0;
}
/**
* Elimina respaldos antiguos que exceden el límite de días (30 días).
*/
protected function cleanupOldBackups($backupDir)
{
$this->info('Revisando si hay respaldos antiguos para limpiar...');
$days = 30; // Conservar los últimos 30 días de respaldos semanales
$files = File::files($backupDir);
foreach ($files as $file) {
// Verificar que sea un archivo de backup
if ($file->getExtension() === 'zip' && strpos($file->getFilename(), 'backup-') === 0) {
$lastModified = $file->getMTime();
$ageInDays = (time() - $lastModified) / (24 * 60 * 60);
if ($ageInDays > $days) {
$this->warn("Eliminando respaldo antiguo: " . $file->getFilename());
File::delete($file->getPathname());
}
}
}
$this->info('Limpieza finalizada.');
}
}
@@ -0,0 +1,284 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Symfony\Component\HttpFoundation\Response;
class BackupController extends Controller
{
/**
* Directorio de respaldos.
*/
protected $backupDir;
public function __construct()
{
$this->backupDir = storage_path('app/backups');
}
/**
* Muestra el panel con el listado de copias de seguridad.
*/
public function index()
{
// Asegurarse de que el directorio exista
if (!File::exists($this->backupDir)) {
File::makeDirectory($this->backupDir, 0755, true);
}
$files = File::files($this->backupDir);
$backups = [];
$totalSize = 0;
foreach ($files as $file) {
if ($file->getExtension() === 'zip' && strpos($file->getFilename(), 'backup-') === 0) {
$size = $file->getSize();
$totalSize += $size;
$backups[] = [
'filename' => $file->getFilename(),
'size' => $this->formatBytes($size),
'raw_size' => $size,
'created_at' => \Carbon\Carbon::createFromTimestamp($file->getMTime())->format('d/m/Y H:i:s'),
'mtime' => $file->getMTime(),
];
}
}
// Ordenar de más nuevo a más viejo
usort($backups, function ($a, $b) {
return $b['mtime'] <=> $a['mtime'];
});
// Estadísticas de disco
$freeDiskSpace = disk_free_space($this->backupDir);
$totalDiskSpace = disk_total_space($this->backupDir);
$diskStats = [
'total_backups_size' => $this->formatBytes($totalSize),
'free_space' => $this->formatBytes($freeDiskSpace),
'total_space' => $this->formatBytes($totalDiskSpace),
'free_percentage' => round(($freeDiskSpace / $totalDiskSpace) * 100, 1),
];
return view('admin.backups.index', compact('backups', 'diskStats'));
}
public function create()
{
try {
$exitCode = Artisan::call('db:backup');
$output = Artisan::output();
if ($exitCode === 0) {
return redirect()->route('admin.backups.index')
->with('success', 'Copia de seguridad generada correctamente.');
} else {
\Illuminate\Support\Facades\Log::error("Artisan db:backup failed with exit code {$exitCode}. Output: " . $output);
return redirect()->route('admin.backups.index')
->with('error', 'Ocurrió un error al generar la copia de seguridad. Detalles: ' . trim($output));
}
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error("Artisan db:backup threw exception: " . $e->getMessage());
return redirect()->route('admin.backups.index')
->with('error', 'Error: ' . $e->getMessage());
}
}
/**
* Descargar una copia de seguridad.
*/
public function download($filename)
{
// Validación de seguridad para evitar directory traversal
if (strpos($filename, '..') !== false || strpos($filename, '/') !== false || strpos($filename, '\\') !== false) {
abort(404, 'Nombre de archivo no válido.');
}
$path = $this->backupDir . '/' . $filename;
if (!File::exists($path)) {
abort(404, 'El archivo no existe.');
}
return response()->download($path);
}
/**
* Eliminar una copia de seguridad.
*/
public function destroy($filename)
{
// Validación de seguridad para evitar directory traversal
if (strpos($filename, '..') !== false || strpos($filename, '/') !== false || strpos($filename, '\\') !== false) {
abort(400, 'Nombre de archivo no válido.');
}
$path = $this->backupDir . '/' . $filename;
if (File::exists($path)) {
File::delete($path);
return redirect()->route('admin.backups.index')
->with('success', 'Copia de seguridad eliminada correctamente.');
}
return redirect()->route('admin.backups.index')
->with('error', 'No se encontró el archivo a eliminar.');
}
/**
* Subir un archivo de copia de seguridad (.zip).
*/
public function upload(Request $request)
{
$request->validate([
'backup_file' => 'required|file|mimes:zip|max:50000', // 50MB max
]);
$file = $request->file('backup_file');
$filename = $file->getClientOriginalName();
// Validación de seguridad para el nombre del archivo
if (strpos($filename, 'backup-') !== 0 || $file->getClientOriginalExtension() !== 'zip') {
return redirect()->route('admin.backups.index')
->with('error', 'El archivo debe ser un .zip válido de respaldo (su nombre debe empezar con "backup-").');
}
// Asegurarse de que el directorio exista
if (!File::exists($this->backupDir)) {
File::makeDirectory($this->backupDir, 0755, true);
}
$file->move($this->backupDir, $filename);
return redirect()->route('admin.backups.index')
->with('success', 'Copia de seguridad subida correctamente.');
}
/**
* Restaurar la base de datos a partir de una copia de seguridad.
*/
public function restore($filename)
{
// Validación de seguridad para evitar directory traversal
if (strpos($filename, '..') !== false || strpos($filename, '/') !== false || strpos($filename, '\\') !== false) {
abort(400, 'Nombre de archivo no válido.');
}
$zipPath = $this->backupDir . '/' . $filename;
if (!File::exists($zipPath)) {
return redirect()->route('admin.backups.index')->with('error', 'El archivo no existe.');
}
// Extraer el archivo SQL del ZIP
$zip = new \ZipArchive();
if ($zip->open($zipPath) === true) {
// Buscamos el primer archivo SQL dentro del zip
$sqlFilename = null;
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
if (pathinfo($stat['name'], PATHINFO_EXTENSION) === 'sql') {
$sqlFilename = $stat['name'];
break;
}
}
if (!$sqlFilename) {
$zip->close();
return redirect()->route('admin.backups.index')->with('error', 'No se encontró ningún archivo SQL en el archivo comprimido.');
}
// Extraer el SQL a un directorio temporal de backups
$zip->extractTo($this->backupDir, $sqlFilename);
$zip->close();
$sqlPath = $this->backupDir . '/' . $sqlFilename;
// Datos de conexión
$dbConfig = config('database.connections.mysql');
$host = $dbConfig['host'] ?? '127.0.0.1';
$port = $dbConfig['port'] ?? '3306';
$database = $dbConfig['database'] ?? 'lauck';
$username = $dbConfig['username'] ?? 'root';
$password = $dbConfig['password'] ?? '';
// Ejecutable de mysql
$mysqlPath = 'mysql';
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
$xamppPath = 'C:\\xampp\\mysql\\bin\\mysql.exe';
if (File::exists($xamppPath)) {
$mysqlPath = $xamppPath;
}
}
// Ejecutar importación usando Process y redirección de entrada de archivo
$command = [
$mysqlPath,
"--host={$host}",
"--port={$port}",
"--user={$username}",
$database
];
$env = [
'SystemRoot' => getenv('SystemRoot') ?: 'C:\\Windows',
'windir' => getenv('windir') ?: 'C:\\Windows',
'PATH' => getenv('PATH'),
];
if ($password !== '') {
$env['MYSQL_PWD'] = $password;
}
$process = new \Symfony\Component\Process\Process($command, null, $env);
// Pasar el contenido del archivo SQL a la entrada estándar (stdin)
$process->setInput(File::get($sqlPath));
$process->setTimeout(300);
try {
$process->run();
// Limpiar archivo SQL temporal
File::delete($sqlPath);
if (!$process->isSuccessful()) {
throw new \Exception(trim($process->getErrorOutput()) ?: 'Error desconocido al restaurar el respaldo.');
}
return redirect()->route('admin.backups.index')
->with('success', 'Base de datos restaurada correctamente a partir del respaldo: ' . $filename);
} catch (\Exception $e) {
if (File::exists($sqlPath)) {
File::delete($sqlPath);
}
\Illuminate\Support\Facades\Log::error("Backup restore failed: " . $e->getMessage());
return redirect()->route('admin.backups.index')
->with('error', 'Error al restaurar base de datos: ' . $e->getMessage());
}
} else {
return redirect()->route('admin.backups.index')->with('error', 'No se pudo abrir el archivo ZIP.');
}
}
/**
* Formatear bytes a tamaño legible.
*/
private function formatBytes($bytes, $precision = 2)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision) . ' ' . $units[$pow];
}
}
@@ -0,0 +1,168 @@
<x-layout title="Respaldos - Lauck">
<x-section-header subtitle="Respaldos" title="Administración de " highlight="Copias de Seguridad" />
<!-- Estadísticas de Disco y Resumen -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 w-full mb-8">
<!-- Tarjeta Espacio Total Utilizado -->
<div class="bg-gray-100 dark:bg-panel-bg p-6 rounded-xl border border-gray-300 dark:border-neutral-800 shadow-md">
<span class="text-xs font-bold text-gray-500 uppercase tracking-widest block mb-1">Espacio en Copias</span>
<div class="text-3xl font-black text-gray-900 dark:text-white font-mono">
{{ $diskStats['total_backups_size'] }}
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">Total acumulado de archivos .zip</p>
</div>
<!-- Tarjeta Espacio Libre en Disco -->
<div class="bg-gray-100 dark:bg-panel-bg p-6 rounded-xl border border-gray-300 dark:border-neutral-800 shadow-md">
<span class="text-xs font-bold text-gray-500 uppercase tracking-widest block mb-1">Espacio Libre del Servidor</span>
<div class="text-3xl font-black text-green-600 dark:text-neon-lime font-mono">
{{ $diskStats['free_space'] }}
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">De un total de {{ $diskStats['total_space'] }}</p>
</div>
<!-- Tarjeta Estado del Disco -->
<div class="bg-gray-100 dark:bg-panel-bg p-6 rounded-xl border border-gray-300 dark:border-neutral-800 shadow-md flex flex-col justify-between">
<div>
<span class="text-xs font-bold text-gray-500 uppercase tracking-widest block mb-1">Capacidad de Almacenamiento</span>
<div class="flex justify-between text-xs text-gray-700 dark:text-gray-300 font-mono mt-1">
<span>Libre: {{ $diskStats['free_percentage'] }}%</span>
<span>Usado: {{ 100 - $diskStats['free_percentage'] }}%</span>
</div>
</div>
<div class="w-full bg-gray-300 dark:bg-neutral-800 rounded-full h-2.5 mt-2 overflow-hidden">
<div class="bg-green-500 dark:bg-neon-lime h-2.5 rounded-full" style="width: {{ $diskStats['free_percentage'] }}%"></div>
</div>
</div>
</div>
<!-- Acciones (Subir / Generar) -->
<div class="w-full flex flex-col md:flex-row justify-between items-center gap-4 mb-6 bg-gray-100 dark:bg-panel-bg p-4 rounded-xl border border-gray-300 dark:border-neutral-800 shadow-md">
<!-- Formulario de Subida -->
<div class="w-full md:w-auto">
<form action="{{ route('admin.backups.upload') }}" method="POST" enctype="multipart/form-data" class="flex flex-col sm:flex-row gap-2 items-center">
@csrf
<div class="flex flex-col">
<label class="block text-[10px] font-bold text-gray-500 uppercase mb-1">Subir Copia de Seguridad (.zip)</label>
<input type="file" name="backup_file" accept=".zip" required class="block w-full text-xs text-gray-900 dark:text-gray-300 border border-gray-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 focus:outline-none file:mr-4 file:py-2 file:px-4 file:border-0 file:text-xs file:font-bold file:bg-neutral-950 dark:file:bg-neon-lime file:text-neon-lime dark:file:text-neutral-900 hover:file:bg-neutral-900 hover:dark:file:bg-[#b3e600] cursor-pointer" />
</div>
<button type="submit" class="w-full sm:w-auto px-5 py-2 text-xs font-bold text-neon-lime dark:text-neutral-900 bg-neutral-950 dark:bg-neon-lime rounded-lg hover:bg-neutral-900/80 hover:dark:bg-[#b3e600] uppercase tracking-wide transition-colors self-end mt-4 sm:mt-0">
Subir Respaldo
</button>
</form>
</div>
<!-- Botón Generar Copia -->
<div class="w-full md:w-auto flex justify-end">
<form action="{{ route('admin.backups.create') }}" method="POST">
@csrf
<button type="submit" class="w-full sm:w-auto text-center px-6 py-3 text-sm font-bold text-neon-lime dark:text-neutral-900 bg-neutral-950 dark:bg-neon-lime rounded-lg hover:bg-neutral-900/80 hover:dark:bg-[#b3e600] transition-colors uppercase tracking-wide flex items-center gap-2 shadow-lg dark:shadow-neon-lime/10">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
Generar Nueva Copia
</button>
</form>
</div>
</div>
<!-- Listado de Archivos -->
<div class="relative overflow-x-auto shadow-md sm:rounded-lg border border-gray-400 dark:border-neutral-800 w-full">
<table class="w-full text-sm text-left rtl:text-right text-neutral-900 dark:text-white">
<thead class="text-xs text-neutral-800 dark:text-gray-300 uppercase bg-gray-400 dark:bg-neutral-800 border-b border-gray-300 dark:border-neutral-700">
<tr>
<th scope="col" class="px-6 py-3">Nombre del Archivo</th>
<th scope="col" class="px-6 py-3">Fecha de Creación</th>
<th scope="col" class="px-6 py-3 font-mono">Tamaño</th>
<th scope="col" class="px-6 py-3 text-right">Acciones</th>
</tr>
</thead>
<tbody>
@forelse($backups as $backup)
<tr class="bg-gray-200/50 dark:bg-neutral-900/50 border-b border-gray-400 dark:border-neutral-800 hover:bg-gray-400 hover:dark:bg-neutral-800 transition-colors group">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-3">
<!-- Icono archivo zip -->
<svg class="size-6 text-gray-500 group-hover:text-gray-900 dark:group-hover:text-neon-lime transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
<span class="font-semibold font-mono text-sm tracking-tight text-gray-900 dark:text-white">
{{ $backup['filename'] }}
</span>
</div>
</td>
<td class="px-6 py-4">
<span class="text-xs text-gray-700 dark:text-gray-300">
{{ $backup['created_at'] }}
</span>
</td>
<td class="px-6 py-4 font-mono text-xs font-semibold text-gray-700 dark:text-gray-300">
{{ $backup['size'] }}
</td>
<td class="px-6 py-4 text-right flex items-center justify-end gap-2">
<!-- Restaurar -->
<form action="{{ route('admin.backups.restore', $backup['filename']) }}" method="POST" class="restore-form inline">
@csrf
<button title="Restaurar Base de Datos" type="submit" class="p-2 font-bold dark:font-medium text-amber-600 dark:text-amber-400 border-2 border-amber-600 rounded-lg hover:bg-amber-600 hover:text-white dark:hover:text-white transition-colors">
<svg class="size-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8m0 0H15.5M21 8V2m0 10a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16m0 0h5.5M3 16v6"/>
</svg>
</button>
</form>
<!-- Descargar -->
<a href="{{ route('admin.backups.download', $backup['filename']) }}" title="Descargar" class="font-semibold p-2 text-blue-700 dark:text-blue-400 border-2 border-blue-700 rounded-lg hover:bg-blue-700 hover:text-white dark:hover:text-white transition-colors">
<svg class="size-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-1m-4-4-4 4m0 0-4-4m4 4V4"/>
</svg>
</a>
<!-- Eliminar -->
<form action="{{ route('admin.backups.destroy', $backup['filename']) }}" method="POST" class="delete-form inline">
@csrf
@method('DELETE')
<button title="Eliminar" type="submit" class="p-2 font-bold dark:font-medium text-red-600 dark:text-red-400 border-2 border-red-600 rounded-lg hover:bg-red-600 hover:text-white dark:hover:text-white transition-colors">
<svg class="size-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-6 py-10 text-center text-gray-500">
<div class="flex flex-col items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mb-3 text-gray-400 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
<p class="text-sm font-medium">No se han generado copias de seguridad de la base de datos todavía.</p>
<p class="text-xs text-gray-400 mt-1">Haga clic en "Generar Nueva Copia" para iniciar una.</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Script para confirmation modal -->
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
const restoreForms = document.querySelectorAll('.restore-form');
restoreForms.forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
if (confirm('¡ATENCIÓN! Está a punto de restaurar la base de datos. Esta acción SOBREESCRIBIRÁ todos los datos actuales con los del archivo de respaldo. ¿Desea continuar?')); else return;
this.submit();
});
});
});
</script>
@endpush
</x-layout>
@@ -2,10 +2,10 @@
@php @php
$borderColor = match($color) { $borderColor = match($color) {
'red' => 'border-l-red-500', 'red' => 'border-l-red-500 dark:border-l-red-500',
'yellow' => 'border-l-yellow-500', 'yellow' => 'border-l-yellow-500 dark:border-l-yellow-500',
'neon' => 'border-l-neon-lime', 'neon' => 'border-l-neon-lime dark:border-l-neon-lime',
default => 'border-l-gray-500' default => 'border-l-gray-500 dark:border-l-gray-500'
}; };
@endphp @endphp
+9
View File
@@ -51,6 +51,15 @@
</svg> </svg>
</x-ui.card> </x-ui.card>
@if(auth()->user()->role === 'admin')
<!-- Tarjeta Respaldos (Solo Admin) -->
<x-ui.card href="{{ route('admin.backups.index') }}" title="Respaldos" description="Copias de seguridad de la base de datos." linkText="Administrar Copias">
<svg class="w-12 h-12 text-lime-500 dark:text-neon-lime" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
</svg>
</x-ui.card>
@endif
</div> </div>
<!-- Botón de Logout --> <!-- Botón de Logout -->
+21 -4
View File
@@ -33,12 +33,30 @@
<input type="email" name="email" required autofocus <input type="email" name="email" required autofocus
class="w-full px-4 py-2 bg-white dark:bg-neutral-900 border border-gray-300 dark:border-neutral-700 text-gray-900 dark:text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-neon-lime focus:border-neon-lime transition-colors"/> class="w-full px-4 py-2 bg-white dark:bg-neutral-900 border border-gray-300 dark:border-neutral-700 text-gray-900 dark:text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-neon-lime focus:border-neon-lime transition-colors"/>
</div> </div>
@endif
<div> <div>
<label class="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Contraseña</label> <label class="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Contraseña</label>
<input type="password" name="password" required <div class="relative">
class="w-full px-4 py-2 bg-white dark:bg-neutral-900 border border-gray-300 dark:border-neutral-700 text-gray-900 dark:text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-neon-lime focus:border-neon-lime transition-colors"/> <input id="passwordInput" type="password" name="password" required
class="w-full px-4 py-2 bg-white dark:bg-neutral-900 border border-gray-300 dark:border-neutral-700 text-gray-900 dark:text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-neon-lime focus:border-neon-lime transition-colors pr-10"/>
<button type="button" id="togglePassword"
class="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-700 dark:hover:text-white transition-colors">
{{-- Ojo abierto --}}
<svg id="iconShow" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{{-- Ojo cerrado --}}
<svg id="iconHide" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 hidden" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.477 0-8.268-2.943-9.542-7a9.956 9.956 0 012.293-3.95M6.938 6.938A9.956 9.956 0 0112 5c4.477 0 8.268 2.943 9.542 7a9.97 9.97 0 01-1.88 3.118M3 3l18 18" />
</svg>
</button>
</div>
<div class="flex justify-end mt-2"> <div class="flex justify-end mt-2">
<a href="{{ route('password.request') }}" class="text-xs font-medium text-green-600 dark:text-neon-lime hover:underline transition-colors"> <a href="{{ route('password.request') }}" class="text-xs font-medium text-green-600 dark:text-neon-lime hover:underline transition-colors">
@@ -46,7 +64,6 @@
</a> </a>
</div> </div>
</div> </div>
</div>
<div> <div>
<button type="submit" <button type="submit"
+4 -5
View File
@@ -68,14 +68,13 @@
Confirmar contraseña Confirmar contraseña
</x-forms.label> </x-forms.label>
<div class="relative"> <div class="relative">
<x-forms.input id="passwordConfirmInput" type="password" name="password_confirmation" required <x-forms.input id="passwordConfirmInput" type="password" name="password_confirmation" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-lime-400 text-gray-600" /> class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-lime-400 text-gray-600 pr-10" />
<button type="button" id="togglePasswordConfirm" <button type="button" id="togglePasswordConfirm"
class="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-700 dark:hover:text-white transition-colors"> class="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-700 dark:hover:text-white transition-colors">
{{-- Ojo abierto --}} {{-- Ojo abierto --}}
<svg id="iconShow" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" <svg id="iconShowConfirm" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
@@ -83,7 +82,7 @@
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg> </svg>
{{-- Ojo cerrado --}} {{-- Ojo cerrado --}}
<svg id="iconHide" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 hidden" fill="none" <svg id="iconHideConfirm" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 hidden" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.477 0-8.268-2.943-9.542-7a9.956 9.956 0 012.293-3.95M6.938 6.938A9.956 9.956 0 0112 5c4.477 0 8.268 2.943 9.542 7a9.97 9.97 0 01-1.88 3.118M3 3l18 18" /> d="M13.875 18.825A10.05 10.05 0 0112 19c-4.477 0-8.268-2.943-9.542-7a9.956 9.956 0 012.293-3.95M6.938 6.938A9.956 9.956 0 0112 5c4.477 0 8.268 2.943 9.542 7a9.97 9.97 0 01-1.88 3.118M3 3l18 18" />
+14 -36
View File
@@ -2,8 +2,6 @@
<x-section-header subtitle="Gestión" title="Tablero de " highlight="Taller" /> <x-section-header subtitle="Gestión" title="Tablero de " highlight="Taller" />
<div class="flex w-full justify-end items-end mb-4 gap-x-2"> <div class="flex w-full justify-end items-end mb-4 gap-x-2">
<a href="{{ route('agenda') }}" <a href="{{ route('agenda') }}"
class="w-full xl:w-auto text-center px-5 py-3 text-sm font-bold text-neon-lime dark:text-neutral-900 bg-neutral-950 dark:bg-neon-lime rounded-lg hover:bg-neutral-900/80 hover:dark:bg-[#b3e600] transition-colors uppercase tracking-wide"> class="w-full xl:w-auto text-center px-5 py-3 text-sm font-bold text-neon-lime dark:text-neutral-900 bg-neutral-950 dark:bg-neon-lime rounded-lg hover:bg-neutral-900/80 hover:dark:bg-[#b3e600] transition-colors uppercase tracking-wide">
@@ -18,16 +16,22 @@
<!-- Contenedor --> <!-- Contenedor -->
<div class="flex flex-col lg:flex-row w-full gap-8 overflow-x-auto pb-4 h-[calc(100vh-250px)]"> <div class="flex flex-col lg:flex-row w-full gap-8 overflow-x-auto pb-4 h-[calc(100vh-250px)]">
<!-- Column 1: Pendientes / A Revisar -->
<div class="flex-1 min-w-[300px] flex flex-col rounded-xl border transition-colors <div class="flex-1 min-w-[300px] flex flex-col rounded-xl border transition-colors
bg-stone-100 border-stone-300 bg-stone-100 border-stone-300
dark:bg-neutral-900/50 dark:border-neutral-800"> dark:bg-neutral-900/50 dark:border-neutral-800">
<!-- COLUMNA 1 --> <div class="p-4 flex justify-between items-center border-b rounded-t-xl
<div class="flex-1 min-w-[300px] bg-gray-50 dark:bg-neutral-900/50 border border-gray-300 dark:border-neutral-800 rounded-xl flex flex-col"> bg-stone-200/50 border-stone-300
<div class="p-4 border-b border-gray-300 dark:border-neutral-800 bg-gray-200 dark:bg-neutral-800/50 rounded-t-xl flex justify-between items-center"> dark:bg-neutral-800/50 dark:border-neutral-800">
<h3 class="font-bold text-gray-800 dark:text-gray-300 uppercase tracking-widest text-xs">🔴 Pendientes / A Revisar</h3> <h3 class="font-bold uppercase tracking-widest text-xs text-red-600 dark:text-gray-300">
<span class="bg-gray-300 dark:bg-neutral-900 text-gray-700 dark:text-gray-400 text-xs px-2 py-1 rounded-full">{{ $pending->count() }}</span> 🔴 Pendientes / A Revisar
</h3>
<span class="text-xs px-2 py-1 rounded-full bg-stone-300 text-stone-700 dark:bg-neutral-900 dark:text-gray-400">
{{ $pending->count() }}
</span>
</div> </div>
<div class="p-4 space-y-4 overflow-y-auto flex-1 custom-scrollbar"> <div class="p-4 space-y-4 overflow-y-auto flex-1 custom-scrollbar">
@foreach($pending as $job) @foreach($pending as $job)
<x-taller-card :job="$job" color="red" /> <x-taller-card :job="$job" color="red" />
@@ -35,34 +39,7 @@
</div> </div>
</div> </div>
<!-- COLUMNA 2 --> <!-- Column 2: En Reparación -->
<div class="flex-1 min-w-[300px] bg-gray-50 dark:bg-neutral-900/50 border border-gray-300 dark:border-neutral-800 rounded-xl flex flex-col">
<div class="p-4 border-b border-gray-300 dark:border-neutral-800 bg-gray-200 dark:bg-neutral-800/50 rounded-t-xl flex justify-between items-center">
<h3 class="font-bold text-yellow-600 dark:text-yellow-500 uppercase tracking-widest text-xs">🟡 En Reparación</h3>
<span class="bg-gray-300 dark:bg-neutral-900 text-gray-700 dark:text-gray-400 text-xs px-2 py-1 rounded-full">{{ $inProgress->count() }}</span>
</div>
<div class="p-4 space-y-4 overflow-y-auto flex-1 custom-scrollbar">
@foreach($inProgress as $job)
<x-taller-card :job="$job" color="yellow" />
@endforeach
</div>
</div>
<!-- COLUMNA 3 -->
<div class="flex-1 min-w-[300px] bg-gray-50 dark:bg-neutral-900/50 border border-gray-300 dark:border-neutral-800 rounded-xl flex flex-col">
<div class="p-4 border-b border-gray-300 dark:border-neutral-800 bg-gray-200 dark:bg-neutral-800/50 rounded-t-xl flex justify-between items-center">
<h3 class="font-bold text-green-600 dark:text-neon-lime uppercase tracking-widest text-xs">🟢 Listas para Retirar</h3>
<span class="bg-gray-300 dark:bg-neutral-900 text-gray-700 dark:text-gray-400 text-xs px-2 py-1 rounded-full">{{ $ready->count() }}</span>
</div>
<div class="p-4 space-y-4 overflow-y-auto flex-1 custom-scrollbar">
@foreach($ready as $job)
<x-taller-card :job="$job" color="neon" />
@endforeach
</div>
</div>
</div>
<div class="flex-1 min-w-[300px] flex flex-col rounded-xl border transition-colors <div class="flex-1 min-w-[300px] flex flex-col rounded-xl border transition-colors
bg-stone-100 border-stone-300 bg-stone-100 border-stone-300
dark:bg-neutral-900/50 dark:border-neutral-800"> dark:bg-neutral-900/50 dark:border-neutral-800">
@@ -85,6 +62,7 @@
</div> </div>
</div> </div>
<!-- Column 3: Listas para Retirar -->
<div class="flex-1 min-w-[300px] flex flex-col rounded-xl border transition-colors <div class="flex-1 min-w-[300px] flex flex-col rounded-xl border transition-colors
bg-stone-100 border-stone-300 bg-stone-100 border-stone-300
dark:bg-neutral-900/50 dark:border-neutral-800"> dark:bg-neutral-900/50 dark:border-neutral-800">
@@ -107,6 +85,6 @@
</div> </div>
</div> </div>
</div> </div>
</x-layout> </x-layout>
+3
View File
@@ -2,7 +2,10 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('db:backup')->weekly();
+11
View File
@@ -15,6 +15,7 @@ use App\Http\Controllers\AppointmentController;
use App\Http\Controllers\SupplierController; use App\Http\Controllers\SupplierController;
use App\Http\Controllers\PasswordResetLinkController; use App\Http\Controllers\PasswordResetLinkController;
use App\Http\Controllers\NewPasswordController; use App\Http\Controllers\NewPasswordController;
use App\Http\Controllers\Admin\BackupController;
use App\Models\Product; use App\Models\Product;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
@@ -75,4 +76,14 @@ Route::middleware(['auth'])->group(function () {
Route::get('/appointments/{appointment}', [AppointmentController::class, 'show']) Route::get('/appointments/{appointment}', [AppointmentController::class, 'show'])
->name('appointments.show'); ->name('appointments.show');
Route::view('/faq', 'faq')->name('faq'); Route::view('/faq', 'faq')->name('faq');
// Copias de Seguridad (Solo Administradores)
Route::middleware(['admin'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/backups', [BackupController::class, 'index'])->name('backups.index');
Route::post('/backups/create', [BackupController::class, 'create'])->name('backups.create');
Route::post('/backups/upload', [BackupController::class, 'upload'])->name('backups.upload');
Route::post('/backups/{filename}/restore', [BackupController::class, 'restore'])->name('backups.restore');
Route::get('/backups/{filename}/download', [BackupController::class, 'download'])->name('backups.download');
Route::delete('/backups/{filename}', [BackupController::class, 'destroy'])->name('backups.destroy');
});
}); });