diff --git a/README.md b/README.md index 779f1a0..ba28c8f 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,24 @@ - **Validación completa** de datos - **Botón de crear cliente** sin salir del formulario +### 🏭 Proveedores y Compras +- **Base de datos de proveedores**: Registro y gestión de datos de contacto +- **Órdenes de compra**: Generación de pedidos a proveedores para reabastecimiento +- **Actualización de inventario**: Vinculación con los productos para reponer stock + +### 📅 Agenda de Turnos +- **Calendario integrado**: Visualización y gestión de citas del taller +- **Detalle de turno**: Información rápida de los turnos programados + +### 💸 Control de Gastos +- **Registro de egresos**: Control de pagos de servicios, insumos y otros gastos del local +- **Categorización**: Descripción y fecha para tener seguimiento de los gastos + +### 💾 Copias de Seguridad (Backups) +- **Generación manual**: Creación de respaldos completos de la base de datos (Solo Administradores) +- **Gestión de archivos**: Descarga, restauración y eliminación de copias de seguridad +- **Interfaz dedicada**: Panel de control específico para proteger la información + ### 🌐 Catálogo Público #### Vista para Visitantes (sin login): - **Listado público** de productos en stock @@ -221,7 +239,8 @@ Client.php // Clientes con validaciones Sale.php // Ventas (cabecera) SaleDetail.php // Detalles de venta (renglones) Appointment.php // Órdenes de taller -Supplier.php // Proveedores (implementación futura) +Supplier.php // Proveedores y pedidos +Expense.php // Gastos del local ``` #### 🎮 Controladores (app/Http/Controllers) @@ -234,6 +253,11 @@ SaleController.php // Sistema de ventas (POS) ClientController.php // Gestión de clientes TallerController.php // Gestión de taller (Kanban) CatalogoController.php // Catálogo público +ExpenseController.php // Gestión de gastos +AgendaController.php // Agenda de turnos +AppointmentController.php // Detalle de turnos +SupplierController.php // Proveedores y pedidos +Admin/BackupController.php // Copias de seguridad (Admin) ``` #### 👁️ Vistas (resources/views) @@ -505,17 +529,7 @@ php artisan migrate:fresh --seed ### Prioridad Alta 🔴 -#### 1. Sistema de Compras a Proveedores -``` -Features: -- Registrar pedidos a proveedores -- Control de órdenes de compra -- Recepción de mercadería -- Actualización automática de stock -- Historial de compras por proveedor -``` - -#### 2. Reportes y Estadísticas +#### 1. Reportes y Estadísticas ``` Dashboard mejorado con: - Ventas del día/mes/año (gráficos) diff --git a/app/Console/Commands/BackupDatabase.php b/app/Console/Commands/BackupDatabase.php new file mode 100644 index 0000000..be93ec1 --- /dev/null +++ b/app/Console/Commands/BackupDatabase.php @@ -0,0 +1,159 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Admin/BackupController.php b/app/Http/Controllers/Admin/BackupController.php new file mode 100644 index 0000000..ada7056 --- /dev/null +++ b/app/Http/Controllers/Admin/BackupController.php @@ -0,0 +1,284 @@ +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]; + } +} diff --git a/app/Http/Controllers/ProductosController.php b/app/Http/Controllers/ProductosController.php index 407143d..496f05b 100644 --- a/app/Http/Controllers/ProductosController.php +++ b/app/Http/Controllers/ProductosController.php @@ -70,13 +70,39 @@ class ProductosController extends Controller 'min_stock_alert' => 'required|integer|min:0', 'type' => 'required|in:bike,accessory,clothing,spare,service,children,skate,rollers,other', 'serial_number' => 'nullable|string|max:100', - 'image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048', + 'image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:20480', 'suppliers_id' => 'required|exists:suppliers,id', 'description' => 'nullable|string' + ], [ + 'image.uploaded' => 'La imagen supera el límite de subida permitido por el servidor.', + 'image.max' => 'La imagen no debe pesar más de 20 MB.', + 'image.image' => 'El archivo debe ser una imagen válida.', + 'image.mimes' => 'La imagen debe tener formato jpeg, png, jpg o webp.', ]); if ($request->hasFile('image')) { - $path = $request->file('image')->store('products', 'public'); + $file = $request->file('image'); + + // Elevar temporalmente el límite de memoria para procesar la imagen + ini_set('memory_limit', '512M'); + + // Usar Intervention Image v4 con driver GD para procesar la imagen + $manager = new \Intervention\Image\ImageManager(new \Intervention\Image\Drivers\Gd\Driver()); + $image = $manager->decode($file->getRealPath()); + + // Redimensionar proporcionalmente si excede 1200px + if ($image->width() > 1200 || $image->height() > 1200) { + $image->scale(width: 1200, height: 1200); + } + + // Comprimir a JPEG con 80% de calidad + $encoded = $image->encode(new \Intervention\Image\Encoders\JpegEncoder(80)); + + // Nombre de archivo único + $path = 'products/' . uniqid() . '.jpg'; + + // Guardar en disco public + Storage::disk('public')->put($path, $encoded->toString()); $validated['image_path'] = $path; } unset($validated['image']); @@ -113,9 +139,14 @@ class ProductosController extends Controller 'stock_quantity' => 'required|integer|min:0', 'min_stock_alert' => 'required|integer|min:0', 'serial_number' => 'nullable|string|max:100', - 'image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048', + 'image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:20480', 'suppliers_id' => 'required|exists:suppliers,id', 'description' => 'nullable|string' + ], [ + 'image.uploaded' => 'La imagen supera el límite de subida permitido por el servidor.', + 'image.max' => 'La imagen no debe pesar más de 20 MB.', + 'image.image' => 'El archivo debe ser una imagen válida.', + 'image.mimes' => 'La imagen debe tener formato jpeg, png, jpg o webp.', ]); if ($request->hasFile('image')) { @@ -123,7 +154,28 @@ class ProductosController extends Controller Storage::disk('public')->delete($product->image_path); } - $path = $request->file('image')->store('products', 'public'); + $file = $request->file('image'); + + // Elevar temporalmente el límite de memoria para procesar la imagen + ini_set('memory_limit', '512M'); + + // Usar Intervention Image v4 con driver GD para procesar la imagen + $manager = new \Intervention\Image\ImageManager(new \Intervention\Image\Drivers\Gd\Driver()); + $image = $manager->decode($file->getRealPath()); + + // Redimensionar proporcionalmente si excede 1200px + if ($image->width() > 1200 || $image->height() > 1200) { + $image->scale(width: 1200, height: 1200); + } + + // Comprimir a JPEG con 80% de calidad + $encoded = $image->encode(new \Intervention\Image\Encoders\JpegEncoder(80)); + + // Nombre de archivo único + $path = 'products/' . uniqid() . '.jpg'; + + // Guardar en disco public + Storage::disk('public')->put($path, $encoded->toString()); $validated['image_path'] = $path; } unset($validated['image']); diff --git a/app/Http/Middleware/CheckAdmin.php b/app/Http/Middleware/CheckAdmin.php new file mode 100644 index 0000000..d3c6ab8 --- /dev/null +++ b/app/Http/Middleware/CheckAdmin.php @@ -0,0 +1,19 @@ +check() && auth()->user()->role === 'admin') { + return $next($request); + } + + abort(403, 'No tienes permiso para entrar aquí.'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..01ddb97 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,9 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withMiddleware(function (Middleware $middleware): void { // + $middleware->alias([ + 'admin' => \App\Http\Middleware\CheckAdmin::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 2a90057..60fd95c 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "intervention/image": "^4.1", "laravel-lang/common": "^6.7", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1" diff --git a/composer.lock b/composer.lock index 566afb2..2764864 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c5327d228d42dad14f6185dd57e05dde", + "content-hash": "54a99d812eb7e5412ada7f4366c3065c", "packages": [ { "name": "archtechx/enums", @@ -1568,6 +1568,150 @@ ], "time": "2025-02-03T10:55:03+00:00" }, + { + "name": "intervention/gif", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "bb395af960deffe64d70c976b4df9283f68e762d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/bb395af960deffe64d70c976b4df9283f68e762d", + "reference": "bb395af960deffe64d70c976b4df9283f68e762d", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/5.0.1" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-05-03T06:04:47+00:00" + }, + { + "name": "intervention/image", + "version": "4.1.2", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "ba4a7cc8042882d479a78b0835f3f0e991e40a71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/ba4a7cc8042882d479a78b0835f3f0e991e40a71", + "reference": "ba4a7cc8042882d479a78b0835f3f0e991e40a71", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^5", + "php": "^8.3" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^4" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io" + } + ], + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/4.1.2" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-05-23T06:51:28+00:00" + }, { "name": "laravel-lang/actions", "version": "1.10.2", diff --git a/database/seeders/ProductosInicialesSeeder.php b/database/seeders/ProductosInicialesSeeder.php index 25cdd60..dd1d557 100644 --- a/database/seeders/ProductosInicialesSeeder.php +++ b/database/seeders/ProductosInicialesSeeder.php @@ -81,5 +81,6 @@ class ProductosInicialesSeeder extends Seeder } $this->command->info('¡Productos y proveedor insertados correctamente!'); + } } \ No newline at end of file diff --git a/public/.htaccess b/public/.htaccess index b574a59..97d3e6b 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -23,3 +23,12 @@ RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^ index.php [L] + + + php_value upload_max_filesize 20M + php_value post_max_size 20M + php_value memory_limit 512M + + + + diff --git a/public/.user.ini b/public/.user.ini new file mode 100644 index 0000000..e55f623 --- /dev/null +++ b/public/.user.ini @@ -0,0 +1,4 @@ +upload_max_filesize = 20M +post_max_size = 20M +max_execution_time = 300 +memory_limit = 256M diff --git a/resources/js/app.js b/resources/js/app.js index 814fd37..c207710 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -120,3 +120,21 @@ document.addEventListener('DOMContentLoaded', function () { }) +// Ver contraseña, con JQuery +$(document).on('click', '#togglePassword', function () { + const input = $('#passwordInput'); + const isPassword = input.attr('type') === 'password'; + + input.attr('type', isPassword ? 'text' : 'password'); + $('#iconShow').toggleClass('hidden', isPassword); + $('#iconHide').toggleClass('hidden', !isPassword); +}); + +$(document).on('click', '#togglePasswordConfirm', function () { + const input = $('#passwordConfirmInput'); + const isPassword = input.attr('type') === 'password'; + + input.attr('type', isPassword ? 'text' : 'password'); + $('#iconShowConfirm').toggleClass('hidden', isPassword); + $('#iconHideConfirm').toggleClass('hidden', !isPassword); +}); \ No newline at end of file diff --git a/resources/views/admin/backups/index.blade.php b/resources/views/admin/backups/index.blade.php new file mode 100644 index 0000000..c12a57b --- /dev/null +++ b/resources/views/admin/backups/index.blade.php @@ -0,0 +1,168 @@ + + + + + +
+ + +
+ Espacio en Copias +
+ {{ $diskStats['total_backups_size'] }} +
+

Total acumulado de archivos .zip

+
+ + +
+ Espacio Libre del Servidor +
+ {{ $diskStats['free_space'] }} +
+

De un total de {{ $diskStats['total_space'] }}

+
+ + +
+
+ Capacidad de Almacenamiento +
+ Libre: {{ $diskStats['free_percentage'] }}% + Usado: {{ 100 - $diskStats['free_percentage'] }}% +
+
+
+
+
+
+
+ + +
+ + +
+
+ @csrf +
+ + +
+ +
+
+ + +
+
+ @csrf + +
+
+
+ + +
+ + + + + + + + + + + @forelse($backups as $backup) + + + + + + + @empty + + + + @endforelse + +
Nombre del ArchivoFecha de CreaciónTamañoAcciones
+
+ + + + + + {{ $backup['filename'] }} + +
+
+ + {{ $backup['created_at'] }} + + + {{ $backup['size'] }} + + +
+ @csrf + +
+ + + + + + + +
+ @csrf + @method('DELETE') + +
+
+
+ + + +

No se han generado copias de seguridad de la base de datos todavía.

+

Haga clic en "Generar Nueva Copia" para iniciar una.

+
+
+
+ + + @push('scripts') + + @endpush + +
diff --git a/resources/views/agenda.blade.php b/resources/views/agenda.blade.php index 2e1185f..c17570a 100644 --- a/resources/views/agenda.blade.php +++ b/resources/views/agenda.blade.php @@ -19,7 +19,7 @@ + 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"> Volver a Taller @@ -57,7 +57,7 @@ Ver turno diff --git a/resources/views/catalogo/index.blade.php b/resources/views/catalogo/index.blade.php index 0484321..afdb06e 100644 --- a/resources/views/catalogo/index.blade.php +++ b/resources/views/catalogo/index.blade.php @@ -78,8 +78,8 @@ -
- @if(request('search') || request('type') || request('tag')) @@ -148,7 +148,7 @@ {{-- Botón --}} + class="font-bold uppercase mt-4 block w-full bg-neutral-900 dark:bg-neon-lime/80 text-neon-lime dark:text-black py-2 rounded-lg text-center shadow-md shadow-gray-900/10 dark:shadow-neon-lime/10 hover:bg-neon-lime hover:dark:bg-neutral-900/70 border border-transparent hover:text-black hover:dark:text-neon-lime hover:border-black hover:dark:border-neon-lime transition-all"> Ver Detalle diff --git a/resources/views/clients/edit.blade.php b/resources/views/clients/edit.blade.php index 2a27041..24528a8 100644 --- a/resources/views/clients/edit.blade.php +++ b/resources/views/clients/edit.blade.php @@ -38,7 +38,7 @@
Cancelar -
diff --git a/resources/views/components/taller-card.blade.php b/resources/views/components/taller-card.blade.php index 239a7e7..85cb378 100644 --- a/resources/views/components/taller-card.blade.php +++ b/resources/views/components/taller-card.blade.php @@ -2,10 +2,10 @@ @php $borderColor = match($color) { - 'red' => 'border-l-red-500', - 'yellow' => 'border-l-yellow-500', - 'neon' => 'border-l-neon-lime', - default => 'border-l-gray-500' + 'red' => 'border-l-red-500 dark:border-l-red-500', + 'yellow' => 'border-l-yellow-500 dark:border-l-yellow-500', + 'neon' => 'border-l-neon-lime dark:border-l-neon-lime', + default => 'border-l-gray-500 dark:border-l-gray-500' }; @endphp diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 2fe2a50..b850dc8 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -57,6 +57,14 @@ + @if(auth()->user()->role === 'admin') + + + + + + + @endif
diff --git a/resources/views/faq.blade.php b/resources/views/faq.blade.php index e0cefe4..e2d97cc 100644 --- a/resources/views/faq.blade.php +++ b/resources/views/faq.blade.php @@ -47,6 +47,38 @@

Genera nuevas ventas, consulta el historial, y gestiona la base de datos de tus clientes para ofrecer un servicio más personalizado.

+ +
+
5
+
+

Proveedores y Pedidos

+

Administra la información de tus proveedores y genera órdenes de compra para reabastecer tu inventario de forma organizada.

+
+
+ +
+
6
+
+

Control de Gastos

+

Registra los gastos operativos del local (servicios, insumos, etc.) para tener un control financiero preciso de tu negocio.

+
+
+ +
+
7
+
+

Agenda de Turnos

+

Organiza las reservas y turnos de los clientes mediante un calendario integrado, facilitando la planificación del tiempo en el taller.

+
+
+ +
+
8
+
+

Copias de Seguridad (Admin)

+

Si tienes rol de administrador, puedes generar, descargar y restaurar copias de seguridad de la base de datos para proteger la información del sistema.

+
+
@@ -101,13 +133,39 @@
- ¿Cómo cambiar entre modo claro y oscuro? + ¿Puedo cambiar el modo oscuro de la página por uno claro?
-

En la barra de navegación superior (Navbar), encontrarás un icono de un sol o una luna (dependiendo del modo actual en el que te encuentres). Haz clic sobre el ícono para alternar entre el tema claro y el oscuro. El sistema recordará tu preferencia para futuras sesiones.

+

Si. El sistema se sincroniza automáticamente con el tema de tu dispositivo. Para alternar entre ambos modos, modificá la preferencia visual directamente desde la configuración de tu navegador web.

+
+
+ + +
+ + ¿Cómo funciona el registro de gastos? + + + + +
+

En la sección Gastos, puedes registrar las salidas de dinero del negocio (por ejemplo, pago de servicios, compra de insumos, mantenimiento). Esto te ayudará a tener una visión más clara de la rentabilidad real de la tienda.

+
+
+ + +
+ + ¿Es seguro el sistema? ¿Cómo evito perder información? + + + + +
+

Los administradores tienen acceso al módulo de Copias de Seguridad (Backups). Desde allí, puedes generar y descargar respaldos de tu base de datos en cualquier momento. Te recomendamos hacer copias periódicas por seguridad y resguardarlas fuera del servidor.

diff --git a/resources/views/login.blade.php b/resources/views/login.blade.php index ad06a6e..a9a728d 100644 --- a/resources/views/login.blade.php +++ b/resources/views/login.blade.php @@ -36,8 +36,27 @@
- +
+ + +
diff --git a/resources/views/productos/edit.blade.php b/resources/views/productos/edit.blade.php index c4061cf..ba85907 100644 --- a/resources/views/productos/edit.blade.php +++ b/resources/views/productos/edit.blade.php @@ -73,7 +73,7 @@
Cancelar -
diff --git a/resources/views/productos/index.blade.php b/resources/views/productos/index.blade.php index 9ab6e84..2534b56 100644 --- a/resources/views/productos/index.blade.php +++ b/resources/views/productos/index.blade.php @@ -1,5 +1,5 @@ - + @@ -46,7 +46,8 @@
- +
@@ -136,7 +137,7 @@
-
+
Cargando productos...
@@ -239,4 +240,4 @@ renderPage(); } - \ No newline at end of file + diff --git a/resources/views/register.blade.php b/resources/views/register.blade.php index 18d03ff..2399167 100644 --- a/resources/views/register.blade.php +++ b/resources/views/register.blade.php @@ -1,95 +1,110 @@ -
- -
+
+ +
-
-
+
+
-
- @csrf + + @csrf - {{-- Bloque de errores --}} - @if ($errors->any()) -
-

- {{ $errors->first() }} -

-
- @endif + {{-- Bloque de errores --}} + @if ($errors->any()) +
+

+ {{ $errors->first() }} +

+
+ @endif
Nombre completo - +
-
- - Correo electrónico - - -
+
+ + Correo electrónico + + +
-
- - Contraseña - - -
+
+ + Contraseña + +
+ + +
-
- - Confirmar contraseña - - -
+
-
- -
+
+ + Confirmar contraseña + +
+ -

- ¿Ya tenés una cuenta? - - Iniciá sesión acá - . -

- + +
+
+
+ +
+ +

+ ¿Ya tenés una cuenta? + + Iniciá sesión acá + . +

+ + +
-
diff --git a/resources/views/sales/index.blade.php b/resources/views/sales/index.blade.php index 782cbd4..24634c8 100644 --- a/resources/views/sales/index.blade.php +++ b/resources/views/sales/index.blade.php @@ -1,27 +1,30 @@ - + -
+
- -
- + +
+
- +
- +
-
- - - @foreach($clients as $client) + @foreach ($clients as $client) @@ -30,25 +33,32 @@
- - + +
- - + +
-
- + @@ -76,63 +90,78 @@ @forelse($sales as $sale) - - + + - + - + - + - + - - + + @empty - - - + + + @endforelse
# Ref Fecha
- #{{ str_pad($sale->id, 5, '0', STR_PAD_LEFT) }} -
+ #{{ str_pad($sale->id, 5, '0', STR_PAD_LEFT) }} + - {{ $sale->created_at->format('d/m/Y H:i') }} - + {{ $sale->created_at->format('d/m/Y H:i') }} + - @if($sale->client) -
{{ $sale->client->name }}
-
{{ $sale->client->phone ?? '' }}
- @else - Consumidor Final - @endif -
+ @if ($sale->client) +
{{ $sale->client->name }}
+
{{ $sale->client->phone ?? '' }} +
+ @else + Consumidor Final + @endif +
- @php - $colors = [ - 'Efectivo' => 'text-white dark:text-green-400 bg-green-600/80 dark:bg-green-900/20 border-green-800', - 'Transferencia' => 'text-white dark:text-blue-400 bg-blue-600/80 dark:bg-blue-900/20 border-blue-800', - 'Tarjeta de Débito' => 'text-white dark:text-purple-400 bg-purple-600/80 dark:bg-purple-900/20 border-purple-800', - 'Tarjeta de Crédito' => 'text-white dark:text-orange-400 bg-orange-600/80 dark:bg-orange-900/20 border-orange-800', - ]; - $badgeClass = $colors[$sale->payment_method] ?? 'bg-gray-400 dark:bg-gray-800 border-gray-700'; - @endphp - - {{ $sale->payment_method }} - - + @php + $colors = [ + 'Efectivo' => + 'text-white dark:text-green-400 bg-green-600/80 dark:bg-green-900/20 border-green-800', + 'Transferencia' => + 'text-white dark:text-blue-400 bg-blue-600/80 dark:bg-blue-900/20 border-blue-800', + 'Tarjeta de Débito' => + 'text-white dark:text-purple-400 bg-purple-600/80 dark:bg-purple-900/20 border-purple-800', + 'Tarjeta de Crédito' => + 'text-white dark:text-orange-400 bg-orange-600/80 dark:bg-orange-900/20 border-orange-800', + ]; + $badgeClass = + $colors[$sale->payment_method] ?? 'bg-gray-400 dark:bg-gray-800 border-gray-700'; + @endphp + + {{ $sale->payment_method }} + + - ${{ number_format($sale->total, 2) }} - + ${{ number_format($sale->total, 2) }} + - - - - - - -
+ + + + + + +
-
- -

No se encontraron ventas registradas.

-
-
+
+ + + + +

No se encontraron ventas registradas.

+
+
@@ -141,4 +170,4 @@
{{ $sales->links() }}
- \ No newline at end of file + diff --git a/resources/views/taller/index.blade.php b/resources/views/taller/index.blade.php index 4da8218..51c01ec 100644 --- a/resources/views/taller/index.blade.php +++ b/resources/views/taller/index.blade.php @@ -1,29 +1,37 @@ - -
- - -
-
-

🔴 Pendientes / A Revisar

- {{ $pending->count() }} + + +
+ +
+

+ 🔴 Pendientes / A Revisar +

+ + {{ $pending->count() }} +
+
@foreach($pending as $job) @@ -31,12 +39,22 @@
- -
-
-

🟡 En Reparación

- {{ $inProgress->count() }} + +
+ +
+

+ 🟡 En Reparación +

+ + {{ $inProgress->count() }} +
+
@foreach($inProgress as $job) @@ -44,12 +62,22 @@
- -
-
-

🟢 Listas para Retirar

- {{ $ready->count() }} + +
+ +
+

+ 🟢 Listas para Retirar +

+ + {{ $ready->count() }} +
+
@foreach($ready as $job) diff --git a/routes/console.php b/routes/console.php index 3c9adf1..2cef2f6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('db:backup')->weekly(); diff --git a/routes/web.php b/routes/web.php index 2d29408..84389d0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,13 +16,13 @@ use App\Http\Controllers\SupplierController; use App\Http\Controllers\PasswordResetLinkController; use App\Http\Controllers\NewPasswordController; use App\Http\Controllers\TagController; +use App\Http\Controllers\Admin\BackupController; use App\Models\Product; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Session; -Route::get('/',HomeController::class); - -// Route::get('/catalogo', [CatalogoController::class,'catalogo'])->name('catalogo'); +// --- RUTAS PÚBLICAS (Cualquiera accede) --- +Route::get('/', HomeController::class); Route::resource('catalogo', CatalogoController::class)->only(['index', 'show'])->parameters(['catalogo' => 'product']); Route::get('login', function(){ return view('login'); })->name('login'); @@ -47,13 +47,13 @@ Route::post('register', RegisterController::class)->name('register.store'); Route::post('logout', function(){ Auth::guard('web')->logout(); - Session::invalidate(); Session::regenerateToken(); - return redirect('/'); })->name('logout'); + +// --- RUTAS PROTEGIDAS (Requieren Login) --- Route::middleware(['auth'])->group(function () { Route::view('dashboard', 'dashboard')->name('dashboard'); Route::resource('clients', ClientController::class); @@ -67,7 +67,10 @@ Route::middleware(['auth'])->group(function () { 'productos' => 'product' ]); Route::resource('sales', SaleController::class)->only(['index', 'create', 'store', 'show']); - // Rutas de Taller + Route::get('/agenda', [AgendaController::class, 'index'])->name('agenda'); + Route::get('/appointments/{appointment}', [AppointmentController::class, 'show'])->name('appointments.show'); + + // Taller Route::controller(TallerController::class)->prefix('taller')->name('taller.')->group(function () { Route::get('/', 'index')->name('index'); Route::get('/create', 'create')->name('create'); @@ -78,4 +81,14 @@ Route::middleware(['auth'])->group(function () { Route::get('/appointments/{appointment}', [AppointmentController::class, 'show']) ->name('appointments.show'); 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'); + }); });