diff --git a/app/Http/Controllers/ProductosController.php b/app/Http/Controllers/ProductosController.php index af0b357..d288937 100644 --- a/app/Http/Controllers/ProductosController.php +++ b/app/Http/Controllers/ProductosController.php @@ -4,67 +4,136 @@ namespace App\Http\Controllers; use App\Models\Product; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; // Necesario para validar unicidad al editar class ProductosController extends Controller { - public function index(){ - $products = Product::orderBy('id','desc')->paginate(); + /** + * Muestra la lista de productos con buscador y paginación. + */ + public function index(Request $request) + { + // Recuperamos lo que el usuario escribió en el buscador (si escribió algo) + $query = $request->input('search'); + + // Construimos la consulta + $products = Product::query() + ->when($query, function ($q) use ($query) { + // Si hay búsqueda, filtra por nombre o SKU + return $q->where('name', 'like', "%{$query}%") + ->orWhere('sku', 'like', "%{$query}%"); + }) + ->orderBy('stock_quantity', 'asc') // Ordenamos primero los que tienen poco stock (Alerta visual) + ->paginate(10) // Paginamos de a 10 + ->withQueryString(); // Mantiene el filtro de búsqueda al cambiar de página + return view('productos.index', compact('products')); } - public function create(){ + /** + * Muestra el formulario de creación. + */ + public function create() + { return view('productos.create'); } - public function show($id){ - $producto = Product::find($id); + /** + * Guarda el producto nuevo en la base de datos. + */ + public function store(Request $request) + { + // 1. Validamos los datos con las nuevas columnas + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'sku' => 'nullable|string|unique:products,sku|max:50', // SKU único + 'description' => 'nullable|string', + 'price' => 'required|numeric|min:0', + 'cost' => 'nullable|numeric|min:0', // Costo opcional + 'stock_quantity' => 'required|integer|min:0', + 'min_stock_alert' => 'required|integer|min:0', + 'type' => 'required|in:bike,accessory,service', // Solo permite estos 3 valores + 'serial_number' => 'nullable|string|max:100', + ]); + + // 2. Si no viene SKU, generamos uno automático (Opcional pero útil) + if (empty($validated['sku'])) { + $validated['sku'] = 'GEN-' . strtoupper(uniqid()); + } + + // 3. Creamos el producto + Product::create($validated); + + // 4. Redireccionamos con mensaje de éxito (Necesitas el componente Alert en el layout) + return redirect()->route('productos.index') + ->with('success', 'Producto creado correctamente.'); + } + + /** + * Muestra el detalle de un producto. + * Usamos Route Model Binding: Laravel busca el ID solo. + */ + public function show(Product $producto) + { return view('productos.show', compact('producto')); } - public function edit($id){ - $producto = Product::find($id); + /** + * Muestra el formulario de edición. + */ + public function edit(Product $producto) + { return view('productos.edit', compact('producto')); } - public function store(Request $request){ - $request->validate([ - 'nombre' => 'required', - 'marca' => 'required', - 'modelo' => 'required', - 'descripcion' => 'required', - 'rodado' => 'required', - 'color' => 'required', - 'tipo' => 'required', - 'precio' => 'required' + /** + * Actualiza el producto existente. + */ + public function update(Request $request, Product $producto) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + // Validamos que el SKU sea único PERO ignoramos el ID de este producto actual + 'sku' => ['nullable', 'string', Rule::unique('products')->ignore($producto->id)], + 'description' => 'nullable|string', + 'price' => 'required|numeric|min:0', + 'cost' => 'nullable|numeric|min:0', + 'stock_quantity' => 'required|integer|min:0', + 'min_stock_alert' => 'required|integer|min:0', + 'type' => 'required|in:bike,accessory,service', + 'serial_number' => 'nullable|string|max:100', ]); - Product::create($request->all()); - return redirect(route('productos.index')); + $producto->update($validated); + + return redirect()->route('productos.index') + ->with('success', 'Producto actualizado exitosamente.'); } - public function update(Request $request,Product $producto){ - $request->validate([ - 'nombre' => 'required', - 'marca' => 'required', - 'modelo' => 'required', - 'descripcion' => 'required', - 'rodado' => 'required', - 'color' => 'required', - 'tipo' => 'required', - 'precio' => 'required' - ]); - $producto->update($request->all()); - return redirect(route('productos.show',$producto)); - } - - public function destroy($id){ - $producto = Product::find($id); + /** + * Elimina el producto. + */ + public function destroy(Product $producto) + { $producto->delete(); - return redirect(route('productos.index')); + return redirect()->route('productos.index') + ->with('success', 'Producto eliminado.'); } - public function vistaUsuario($id){ - $producto = Product::find($id); - return view('productos.vistaUsuario', compact('producto')); + /** + * Vista simplificada para consultar precios (empleados). + */ + public function checker(Request $request) + { + $search = $request->input('query'); + $result = null; + + if ($search) { + $result = Product::where('sku', $search) + ->orWhere('name', 'like', "%{$search}%") + ->first(); // Devuelve el primer resultado encontrado + } + + return view('productos.checker', compact('result', 'search')); } -} +} \ No newline at end of file diff --git a/app/Models/Appointment.php b/app/Models/Appointment.php new file mode 100644 index 0000000..85d1752 --- /dev/null +++ b/app/Models/Appointment.php @@ -0,0 +1,30 @@ + 'datetime', // Laravel lo convierte a objeto Carbon automáticamente + ]; + + // Relación: Un turno pertenece a un cliente + public function client() + { + return $this->belongsTo(Client::class); + } +} diff --git a/app/Models/Client.php b/app/Models/Client.php new file mode 100644 index 0000000..236bd9b --- /dev/null +++ b/app/Models/Client.php @@ -0,0 +1,19 @@ +hasMany(Appointment::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index 143a9b8..bda64e3 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -8,14 +8,21 @@ use Illuminate\Database\Eloquent\Model; class Product extends Model { use HasFactory; + protected $fillable = [ - 'nombre', - 'marca', - 'modelo', - 'rodado', - 'color', - 'tipo', - 'descripcion', - 'precio' + 'name', + 'sku', + 'description', + 'price', + 'cost', + 'stock_quantity', + 'min_stock_alert', + 'type', + 'serial_number' ]; + + public function hasLowStock(): bool + { + return $this->stock_quantity <= $this->min_stock_alert; + } } diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 3210886..8cbc82a 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -16,15 +16,36 @@ class ProductFactory extends Factory */ public function definition(): array { + $brands = ['Shimano', 'Venzo', 'Trek', 'Specialized', 'Maxxis', 'Sram', 'Raleigh']; + $bikeModels = ['Loki', 'Marlin 5', 'Chisel', 'Talon 3', 'Aspect 950']; + $accessories = ['Cadena 9v', 'Pedales Aluminio', 'Casco MTB', 'Luz Delantera USB', 'Cámara 29"', 'Cubierta Kevlar']; + + $type = $this->faker->randomElement(['bike', 'accessory', 'service']); + + // Generar nombre según el tipo + if ($type === 'bike') { + $name = $this->faker->randomElement($brands) . ' ' . $this->faker->randomElement($bikeModels); + } elseif ($type === 'accessory') { + $name = $this->faker->randomElement($accessories) . ' ' . $this->faker->randomElement(['Pro', 'Basic', 'Comp', 'Elite']); + } else { + $name = $this->faker->randomElement(['Service General', 'Ajuste Cambios', 'Centrado de Rueda', 'Lavado y Engrase']); + } + + // Lógica de Precios + $price = $this->faker->numberBetween(5000, 800000); + $cost = $price * $this->faker->randomFloat(2, 0.5, 0.7); + return [ - 'nombre' => $this->faker->sentence(3), - 'marca'=> $this->faker->randomElement(['Cube','Giant','Megamo','KTM','MMR','Liv','Ghost','Lapierre']), - 'modelo'=> $this->faker->bothify('????-####'), - 'rodado'=> $this->faker->numberBetween(12,30), - 'color'=> $this->faker->safeColorName(), - 'tipo'=> $this->faker->word(), - 'descripcion'=> $this->faker->text(), - 'precio'=> $this->faker->randomFloat(2,100,200) + 'name' => $name, + 'sku' => strtoupper($this->faker->bothify('???-#####')), + 'description' => $this->faker->sentence(10), + 'price' => $price, + 'cost' => $cost, + 'stock_quantity' => $type === 'service' ? 0 : $this->faker->numberBetween(0, 50), + 'min_stock_alert' => $this->faker->numberBetween(2, 10), + 'type' => $type, + // Nro de serie si es bicicleta + 'serial_number' => $type === 'bike' ? strtoupper($this->faker->bothify('##??##??')) : null, ]; } } diff --git a/database/migrations/2025_08_05_005156_create_products_table.php b/database/migrations/2025_08_05_005156_create_products_table.php index ecbf13a..d044a7f 100644 --- a/database/migrations/2025_08_05_005156_create_products_table.php +++ b/database/migrations/2025_08_05_005156_create_products_table.php @@ -13,14 +13,19 @@ return new class extends Migration { Schema::create('products', function (Blueprint $table) { $table->id(); - $table->string('nombre'); - $table->string('marca'); - $table->string('modelo'); - $table->string('rodado'); - $table->string('color'); - $table->string('tipo'); - $table->text('descripcion'); - $table->float('precio'); + $table->string('name'); + $table->string('sku')->unique()->nullable(); // Código de barras o interno + $table->text('description')->nullable(); + + $table->decimal('price', 10, 2); // Precio venta + $table->decimal('cost', 10, 2)->nullable(); // Costo (solo admin) + + $table->integer('stock_quantity')->default(0); + $table->integer('min_stock_alert')->default(5); // Alerta + + $table->enum('type', ['bike', 'accessory', 'service']); + $table->string('serial_number')->nullable(); // Solo para bicis + $table->timestamps(); }); } diff --git a/database/migrations/2025_12_06_063046_add_role_to_users_table.php b/database/migrations/2025_12_06_063046_add_role_to_users_table.php new file mode 100644 index 0000000..78352f6 --- /dev/null +++ b/database/migrations/2025_12_06_063046_add_role_to_users_table.php @@ -0,0 +1,28 @@ +enum('role', ['admin', 'employee'])->default('employee')->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('role'); + }); + } +}; diff --git a/database/migrations/2025_12_06_063539_create_clients_table.php b/database/migrations/2025_12_06_063539_create_clients_table.php new file mode 100644 index 0000000..8699303 --- /dev/null +++ b/database/migrations/2025_12_06_063539_create_clients_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('phone')->nullable(); // Clave para WhatsApp + $table->string('email')->nullable(); + $table->text('address')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('clients'); + } +}; diff --git a/database/migrations/2025_12_06_063714_create_appointments_table.php b/database/migrations/2025_12_06_063714_create_appointments_table.php new file mode 100644 index 0000000..3989e83 --- /dev/null +++ b/database/migrations/2025_12_06_063714_create_appointments_table.php @@ -0,0 +1,39 @@ +id(); + + // Si se borra cliente, se borran sus turnos + $table->foreignId('client_id')->constrained()->onDelete('cascade'); + + $table->dateTime('scheduled_at'); // Fecha y hora del turno + $table->string('bike_model'); + $table->text('problem_description'); // Ej: "Hace ruido la caja" + + $table->enum('status', ['pending', 'confirmed', 'in_progress', 'ready', 'delivered']) + ->default('pending'); + + $table->text('notes')->nullable(); // Notas internas del mecánico + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('appointments'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index db087de..3cb42e7 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,26 +2,71 @@ namespace Database\Seeders; -use App\Models\Product; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use App\Models\User; +use App\Models\Product; +use App\Models\Client; +use App\Models\Appointment; +// use Illuminate\Database\Console\Seeds\WithoutModelEvents; class DatabaseSeeder extends Seeder { - /** - * Seed the application's database. - */ public function run(): void { - User::factory(10)->create(); - + //Crear el Super Admin User::factory()->create([ - 'name' => 'admin', - 'email' => 'admin@laravel.com', - 'password' => 'admin' + 'name' => 'Jose Admin', + 'email' => 'admin@lauck.com', + 'password' => bcrypt('password'), // Cambiar en producción + 'role' => 'admin', ]); - Product::factory(50)->create(); + //Crear un Empleado de prueba + User::factory()->create([ + 'name' => 'Empleado Test', + 'email' => 'taller@lauck.com', + 'password' => bcrypt('password'), + 'role' => 'employee', + ]); + + //Crear Productos + Product::create([ + 'name' => 'Cámara 29 Válvula Auto', + 'sku' => 'CAM-29-A', + 'price' => 5000, + 'cost' => 2500, + 'stock_quantity' => 20, + 'min_stock_alert' => 5, + 'type' => 'accessory' + ]); + + Product::create([ + 'name' => 'Venzo Loki Evo 29', + 'sku' => 'BIC-VEN-001', + 'price' => 450000, + 'cost' => 300000, + 'stock_quantity' => 2, + 'min_stock_alert' => 1, + 'type' => 'bike', + 'serial_number' => 'VZ998877' + ]); + + // Generar 10 productos aleatorios más + Product::factory(10)->create(); + + // Clientes y Turnos + $client = Client::create([ + 'name' => 'Juan Perez', + 'phone' => '1122334455', + 'email' => 'juan@gmail.com' + ]); + + Appointment::create([ + 'client_id' => $client->id, + 'scheduled_at' => now()->addDays(1)->setHour(10)->setMinute(0), + 'bike_model' => 'Trek Marlin 5', + 'problem_description' => 'Service completo y ajuste de cambios', + 'status' => 'pending' + ]); } } diff --git a/resources/a.md b/resources/a.md new file mode 100644 index 0000000..ad025d6 --- /dev/null +++ b/resources/a.md @@ -0,0 +1,165 @@ +¡Perfecto\! Tus respuestas aclaran muchísimo el panorama. Al ser un sistema **Intranet (Local)** con un **Admin** y **Empleados** (visores/operadores), y con procesos manuales en el taller, podemos simplificar la arquitectura para lanzar un MVP (Producto Mínimo Viable) rápido y robusto, pero preparado para crecer. + +Aquí tienes la **Guía Maestra de Desarrollo para Lauck Systems**. + +----- + +### 1\. Arquitectura de Base de Datos (El Cimiento) + +Antes de escribir código, definamos las tablas. Laravel hace esto fácil con migraciones. + +#### Tablas Principales: + +1. **`users`** (Ya viene en Laravel) + + * Agregamos campo: `role` (enum: 'admin', 'employee'). + * *Lógica:* El Admin crea, edita y borra. El Empleado solo ve y registra ventas/turnos (según definas). + +2. **`clients`** (Clientes) + + * `name`, `phone` (clave para WhatsApp), `email`, `address`. + * *Nota:* Fundamental para agendar turnos y registrar garantías. + +3. **`products`** (Inventario Mixto) + + * `name`, `sku` (código único), `description`. + * `price` (precio venta), `cost` (costo, solo visible para admin). + * `stock_quantity` (entero). + * `min_stock_alert` (entero, para la alerta que pediste). + * `type` (enum: 'bike', 'accessory', 'service'). + * `serial_number` (nullable, solo para bicicletas). + +4. **`appointments`** (Turnos / Taller) + + * `client_id` (relación). + * `scheduled_at` (datetime - fecha y hora del turno). + * `bike_model` (texto libre, ej: "Venzo Loki 29"). + * `problem_description` (motivo de la consulta). + * `status` (enum: 'pending', 'confirmed', 'in\_progress', 'ready', 'delivered'). + * `notes` (uso interno del mecánico). + +5. **`sales`** (Ventas Internas / Historial) + + * `user_id` (quién vendió). + * `client_id` (opcional, si es consumidor final anónimo). + * `total_amount`. + * `payment_method` (efectivo, tarjeta, transferencia). + * `created_at` (fecha de venta). + +6. **`sale_items`** (Detalle de venta) + + * `sale_id`, `product_id`, `quantity`, `unit_price`. + +----- + +### 2\. Estructura de Rutas y Controladores (Backend) + +En Laravel, organizaremos esto por "Dominios". + + * **Autenticación:** (Ya lo tienes con Breeze/Jetstream). + * **DashboardController:** + * `index()`: Muestra las tarjetas, alertas de stock bajo (query simple `Product::whereColumn('stock', '<=', 'min_stock')->get()`) y turnos de hoy. + * **ProductController:** + * CRUD completo (Crear, Leer, Actualizar, Borrar). + * Función extra `search()`: Para el buscador de precios de los empleados. + * **ClientController:** + * CRUD simple. + * **AppointmentController (Gestión de Taller):** + * `calendar()`: Vista de calendario o lista cronológica. + * `statusUpdate()`: Para mover el turno de "Pendiente" a "Listo". + * **SaleController:** + * `create()`: Formulario para registrar una salida de mercadería. + * `store()`: Resta el stock y guarda la venta. + +----- + +### 3\. Componentes de UI (Frontend - Blade + Tailwind) + +Para mantener el estilo "Lauck" que ya definimos, necesitaremos crear estos componentes reutilizables (además de los que ya tienes): + +1. **`x-ui.status-badge`**: + * Una etiqueta pequeña redondeada que cambia de color según el estado (Verde para 'Stock Alto', Rojo para 'Sin Stock' o 'Turno Atrasado', Amarillo para 'En Reparación'). +2. **`x-ui.table`**: + * Una tabla estilizada con el modo oscuro, filas alternadas y cabeceras fijas. Vital para listas de precios y clientes. +3. **`x-forms.input` / `x-forms.select`**: + * Inputs con el estilo oscuro y borde neón al hacer foco, para no repetir las clases de Tailwind en cada formulario. +4. **`x-ui.alert`**: + * Para mostrar mensajes de éxito ("Producto guardado") o alertas ("¡Quedan solo 2 cámaras rodado 29\!"). + +----- + +### 4\. Funcionalidades Específicas a Implementar + +Aquí está la lógica para los requerimientos que mencionaste: + +#### A. Alertas de Stock ⚠️ + + * **Lógica:** No necesitas un sistema complejo de notificaciones en tiempo real todavía. + * **Implementación:** En el `DashboardController`, pasas una variable `$lowStockProducts` a la vista. + * **Vista:** En el Dashboard, si esa lista no está vacía, muestras una tarjeta roja o amarilla avisando "X productos con stock crítico". + +#### B. Consulta de Precios (Modo Solo Lectura) 🔍 + + * **Requerimiento:** Empleados consultan, no editan. + * **Implementación:** + * Crear una vista `products.checker`. + * Un input de búsqueda grande en el centro. + * Al escribir (AJAX o Livewire sería ideal aquí, pero un form simple con botón "Buscar" funciona), muestra una tarjeta gigante con el Nombre y el Precio. + * *Seguridad:* Usar **Laravel Gates** o **Policies**. + ```php + // En AuthServiceProvider + Gate::define('edit-products', function ($user) { + return $user->role === 'admin'; + }); + ``` + En Blade: `@can('edit-products') @endcan`. Así el empleado ve el producto pero no el botón de editar. + +#### C. Agenda de Turnos 📅 + + * **Lógica:** "Consulta -\> Cita". + * **Implementación:** + * No te compliques con un calendario visual complejo (tipo Google Calendar) al principio. + * Usa una **Lista Agrupada por Días**. + * *Ejemplo visual:* + * **HOY:** + * 09:00 - Jose (Pincharura) [Ver] + * 10:30 - Maria (Service General) [Ver] + * **MAÑANA:** + * ... + +----- + +### 5\. Roadmap Sugerido (Paso a Paso) + +Este es el orden lógico para programar sin perderse: + +1. **Semana 1: Cimientos y Stock (Lo más urgente)** + + * Configurar Migraciones (`products`, `clients`). + * Crear Modelos y Seeders (datos falsos para probar). + * Hacer el CRUD de Productos (Alta, Baja y Modificación). + * *Hito:* Poder cargar una bicicleta y verla en la lista. + +2. **Semana 2: Seguridad y Consultas** + + * Agregar campo `role` a Users. + * Crear la vista "Consulta de Precios" (solo lectura). + * Proteger las rutas de edición para que solo el Admin entre. + * *Hito:* El empleado puede loguearse y buscar un precio, pero no borrar nada. + +3. **Semana 3: El Taller (Turnos)** + + * Crear migración `appointments`. + * Crear formulario para "Nuevo Turno" (Seleccionar Cliente + Fecha + Motivo). + * Crear vista de "Lista de Turnos" en el Dashboard. + * *Hito:* Dejar de usar el cuaderno de papel para los turnos. + +4. **Semana 4: Refinamiento** + + * Alertas de stock visuales. + * Mejoras estéticas (Dark Mode en tablas). + * Pruebas finales en el servidor local. + +### ¿Cómo seguimos? + +¿Te gustaría que generemos el código para la **Migración de Productos** y el **Modelo**, o prefieres que diseñemos primero el componente visual de la **Tabla de Stock**? \ No newline at end of file diff --git a/resources/views/components/alert.blade.php b/resources/views/components/alert.blade.php deleted file mode 100644 index f787036..0000000 --- a/resources/views/components/alert.blade.php +++ /dev/null @@ -1,25 +0,0 @@ -@props(['type' => 'dark']) - -@php - switch ($type) { - case 'info': - $class = 'text-blue-800 bg-blue-50 dark:text-blue-400'; - break; - case 'danger': - $class = 'text-red-800 bg-red-50 dark:text-red-400'; - break; - case 'success': - $class = 'text-green-800 bg-green-50 dark:text-green-400'; - break; - case 'warning': - $class = 'text-yellow-800 bg-yellow-50 dark:text-yellow-300'; - break; - default: - $class = 'text-gray-800 bg-gray-50 dark:text-gray-300'; - break; - } -@endphp - -
{{ $error }}
+@endif \ No newline at end of file diff --git a/resources/views/components/forms/label.blade.php b/resources/views/components/forms/label.blade.php new file mode 100644 index 0000000..e729c95 --- /dev/null +++ b/resources/views/components/forms/label.blade.php @@ -0,0 +1,5 @@ +@props(['value']) + + diff --git a/resources/views/components/forms/select.blade.php b/resources/views/components/forms/select.blade.php new file mode 100644 index 0000000..1346f60 --- /dev/null +++ b/resources/views/components/forms/select.blade.php @@ -0,0 +1,13 @@ +@props(['disabled' => false, 'options', 'error' => null]) + + +@if($error) +{{ $error }}
+@endif \ No newline at end of file diff --git a/resources/views/components/ui/alert.blade.php b/resources/views/components/ui/alert.blade.php new file mode 100644 index 0000000..6ec3194 --- /dev/null +++ b/resources/views/components/ui/alert.blade.php @@ -0,0 +1,11 @@ +@if (session('success')) +