Docs: Creado documento RECOMENDACIONES, modificado README
This commit is contained in:
@@ -610,363 +610,6 @@ Dashboard mejorado con:
|
|||||||
- Reportes consolidados
|
- Reportes consolidados
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Mejoras Técnicas Recomendadas
|
|
||||||
|
|
||||||
### Optimización de Rendimiento
|
|
||||||
|
|
||||||
#### 1. Implementar Caché Estratégico
|
|
||||||
```php
|
|
||||||
// ProductosController.php
|
|
||||||
public function index(Request $request)
|
|
||||||
{
|
|
||||||
$cacheKey = 'products.' . md5(serialize($request->all()));
|
|
||||||
|
|
||||||
$products = Cache::remember($cacheKey, 300, function () use ($request) {
|
|
||||||
return Product::query()
|
|
||||||
->when($request->search, fn($q, $term) =>
|
|
||||||
$q->where('name', 'like', "%{$term}%")
|
|
||||||
)
|
|
||||||
->paginate(10);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limpiar caché al modificar productos
|
|
||||||
Cache::forget('products.*');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Eager Loading para Relaciones
|
|
||||||
```php
|
|
||||||
// Evitar N+1 queries
|
|
||||||
$sales = Sale::with(['client', 'details.product'])
|
|
||||||
->latest()
|
|
||||||
->paginate(15);
|
|
||||||
|
|
||||||
// En lugar de:
|
|
||||||
$sales = Sale::all(); // Genera queries adicionales
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Índices de Base de Datos
|
|
||||||
```php
|
|
||||||
// En migraciones
|
|
||||||
Schema::table('products', function (Blueprint $table) {
|
|
||||||
$table->index('sku');
|
|
||||||
$table->index('type');
|
|
||||||
$table->index(['stock_quantity', 'min_stock_alert']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::table('sales', function (Blueprint $table) {
|
|
||||||
$table->index('created_at');
|
|
||||||
$table->index(['client_id', 'created_at']);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Paginación con Cursor
|
|
||||||
```php
|
|
||||||
// Para datasets grandes
|
|
||||||
$products = Product::latest()->cursorPaginate(20);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Refactorización de Código
|
|
||||||
|
|
||||||
#### 1. Services para Lógica de Negocio
|
|
||||||
```php
|
|
||||||
// app/Services/SaleService.php
|
|
||||||
class SaleService
|
|
||||||
{
|
|
||||||
public function processSale(array $data): Sale
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($data) {
|
|
||||||
$sale = Sale::create([...]);
|
|
||||||
|
|
||||||
foreach ($data['items'] as $item) {
|
|
||||||
$this->addItem($sale, $item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $sale;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function addItem(Sale $sale, array $item): void
|
|
||||||
{
|
|
||||||
$product = Product::findOrFail($item['product_id']);
|
|
||||||
|
|
||||||
if ($product->stock_quantity < $item['quantity']) {
|
|
||||||
throw new InsufficientStockException();
|
|
||||||
}
|
|
||||||
|
|
||||||
SaleDetail::create([...]);
|
|
||||||
$product->decrement('stock_quantity', $item['quantity']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Form Request Validation
|
|
||||||
```php
|
|
||||||
// app/Http/Requests/StoreSaleRequest.php
|
|
||||||
class StoreSaleRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'client_id' => 'nullable|exists:clients,id',
|
|
||||||
'payment_method' => 'required|string',
|
|
||||||
'items' => 'required|array|min:1',
|
|
||||||
'items.*.product_id' => 'required|exists:products,id',
|
|
||||||
'items.*.quantity' => 'required|integer|min:1',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function messages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'items.required' => 'Debe agregar al menos un producto.',
|
|
||||||
'items.*.quantity.min' => 'La cantidad debe ser mayor a 0.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// En el controlador
|
|
||||||
public function store(StoreSaleRequest $request)
|
|
||||||
{
|
|
||||||
// $request ya está validado
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Repository Pattern
|
|
||||||
```php
|
|
||||||
// app/Repositories/ProductRepository.php
|
|
||||||
interface ProductRepositoryInterface
|
|
||||||
{
|
|
||||||
public function search(string $term): Collection;
|
|
||||||
public function lowStock(): Collection;
|
|
||||||
public function byType(string $type): Collection;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProductRepository implements ProductRepositoryInterface
|
|
||||||
{
|
|
||||||
public function search(string $term): Collection
|
|
||||||
{
|
|
||||||
return Product::where('name', 'like', "%{$term}%")
|
|
||||||
->orWhere('sku', 'like', "%{$term}%")
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registrar en AppServiceProvider
|
|
||||||
$this->app->bind(
|
|
||||||
ProductRepositoryInterface::class,
|
|
||||||
ProductRepository::class
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Policies para Autorización
|
|
||||||
```php
|
|
||||||
// app/Policies/ProductPolicy.php
|
|
||||||
class ProductPolicy
|
|
||||||
{
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
return in_array($user->role, ['admin', 'employee']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
return $user->role === 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(User $user, Product $product): bool
|
|
||||||
{
|
|
||||||
return $user->role === 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(User $user, Product $product): bool
|
|
||||||
{
|
|
||||||
return $user->role === 'admin';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// En controlador
|
|
||||||
$this->authorize('create', Product::class);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mejoras de Seguridad
|
|
||||||
|
|
||||||
#### 1. Rate Limiting Específico
|
|
||||||
```php
|
|
||||||
// routes/web.php
|
|
||||||
Route::post('/sales', [SaleController::class, 'store'])
|
|
||||||
->middleware(['auth', 'throttle:sales']);
|
|
||||||
|
|
||||||
// app/Providers/RouteServiceProvider.php
|
|
||||||
RateLimiter::for('sales', function (Request $request) {
|
|
||||||
return Limit::perMinute(10)->by($request->user()?->id);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Sanitización de Inputs
|
|
||||||
```php
|
|
||||||
// En Form Request
|
|
||||||
protected function prepareForValidation()
|
|
||||||
{
|
|
||||||
$this->merge([
|
|
||||||
'name' => strip_tags($this->name),
|
|
||||||
'description' => Purifier::clean($this->description),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Protección CSRF Explícita
|
|
||||||
```php
|
|
||||||
// Verificar en AJAX
|
|
||||||
$.ajaxSetup({
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Validación de Imágenes
|
|
||||||
```php
|
|
||||||
'image' => [
|
|
||||||
'nullable',
|
|
||||||
'image',
|
|
||||||
'mimes:jpeg,png,jpg,webp',
|
|
||||||
'max:2048', // 2MB
|
|
||||||
'dimensions:min_width=100,min_height=100,max_width=2000,max_height=2000'
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mejoras de UI/UX
|
|
||||||
|
|
||||||
#### 1. Loading States
|
|
||||||
```blade
|
|
||||||
<button type="submit" id="submit-btn" class="...">
|
|
||||||
<span class="loading hidden">
|
|
||||||
<svg class="animate-spin h-5 w-5" ...></svg>
|
|
||||||
</span>
|
|
||||||
<span class="text">Guardar</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
$('#form').on('submit', function() {
|
|
||||||
$('#submit-btn .loading').removeClass('hidden');
|
|
||||||
$('#submit-btn .text').addClass('hidden');
|
|
||||||
$('#submit-btn').prop('disabled', true);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Confirmaciones Elegantes
|
|
||||||
```javascript
|
|
||||||
// Usar SweetAlert2
|
|
||||||
Swal.fire({
|
|
||||||
title: '¿Eliminar producto?',
|
|
||||||
text: "Esta acción no se puede revertir",
|
|
||||||
icon: 'warning',
|
|
||||||
showCancelButton: true,
|
|
||||||
confirmButtonColor: '#ccff00',
|
|
||||||
cancelButtonColor: '#d33',
|
|
||||||
confirmButtonText: 'Sí, eliminar',
|
|
||||||
cancelButtonText: 'Cancelar'
|
|
||||||
}).then((result) => {
|
|
||||||
if (result.isConfirmed) {
|
|
||||||
// Ejecutar eliminación
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Toast Notifications
|
|
||||||
```javascript
|
|
||||||
// Mensajes no intrusivos
|
|
||||||
const Toast = Swal.mixin({
|
|
||||||
toast: true,
|
|
||||||
position: 'top-end',
|
|
||||||
showConfirmButton: false,
|
|
||||||
timer: 3000
|
|
||||||
});
|
|
||||||
|
|
||||||
Toast.fire({
|
|
||||||
icon: 'success',
|
|
||||||
title: 'Producto guardado'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Validación en Tiempo Real
|
|
||||||
```javascript
|
|
||||||
$('input[name="sku"]').on('blur', function() {
|
|
||||||
const sku = $(this).val();
|
|
||||||
|
|
||||||
$.get(`/api/products/check-sku/${sku}`, function(data) {
|
|
||||||
if (data.exists) {
|
|
||||||
// Mostrar error
|
|
||||||
$(this).addClass('border-red-500');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
#### 1. Tests de Feature
|
|
||||||
```php
|
|
||||||
// tests/Feature/ProductTest.php
|
|
||||||
public function test_admin_can_create_product()
|
|
||||||
{
|
|
||||||
$admin = User::factory()->create(['role' => 'admin']);
|
|
||||||
|
|
||||||
$this->actingAs($admin)
|
|
||||||
->post('/productos', [
|
|
||||||
'name' => 'Test Product',
|
|
||||||
'price' => 100,
|
|
||||||
'stock_quantity' => 10,
|
|
||||||
'min_stock_alert' => 5,
|
|
||||||
'type' => 'accessory'
|
|
||||||
])
|
|
||||||
->assertRedirect('/productos')
|
|
||||||
->assertSessionHas('success');
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('products', [
|
|
||||||
'name' => 'Test Product'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_guest_cannot_create_product()
|
|
||||||
{
|
|
||||||
$response = $this->post('/productos', []);
|
|
||||||
$response->assertRedirect('/login');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Tests Unitarios
|
|
||||||
```php
|
|
||||||
// tests/Unit/ProductTest.php
|
|
||||||
public function test_product_has_low_stock()
|
|
||||||
{
|
|
||||||
$product = Product::factory()->create([
|
|
||||||
'stock_quantity' => 3,
|
|
||||||
'min_stock_alert' => 5
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertTrue($product->hasLowStock());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Browser Tests (Dusk)
|
|
||||||
```php
|
|
||||||
// tests/Browser/PosTest.php
|
|
||||||
public function test_can_complete_sale()
|
|
||||||
{
|
|
||||||
$this->browse(function (Browser $browser) {
|
|
||||||
$browser->loginAs(User::find(1))
|
|
||||||
->visit('/sales/create')
|
|
||||||
->select('items[0][product_id]', 1)
|
|
||||||
->type('items[0][quantity]', 2)
|
|
||||||
->press('Confirmar Venta')
|
|
||||||
->assertPathIs('/sales/1');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🐛 Issues Conocidos y Soluciones
|
## 🐛 Issues Conocidos y Soluciones
|
||||||
|
|
||||||
### 1. Select2 no se inicializa en filas dinámicas
|
### 1. Select2 no se inicializa en filas dinámicas
|
||||||
@@ -1096,7 +739,7 @@ Este proyecto está bajo la Licencia MIT. Ver archivo `LICENSE` para detalles.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Versión**: 2.0.0
|
**Versión**: 0.2.0
|
||||||
**Estado**: En desarrollo activo 🚧
|
**Estado**: En desarrollo activo 🚧
|
||||||
**Última actualización**: Enero 2026
|
**Última actualización**: Enero 2026
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
## 🔧 Mejoras Técnicas Recomendadas
|
||||||
|
|
||||||
|
### Optimización de Rendimiento
|
||||||
|
|
||||||
|
#### 1. Implementar Caché Estratégico
|
||||||
|
```php
|
||||||
|
// ProductosController.php
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$cacheKey = 'products.' . md5(serialize($request->all()));
|
||||||
|
|
||||||
|
$products = Cache::remember($cacheKey, 300, function () use ($request) {
|
||||||
|
return Product::query()
|
||||||
|
->when($request->search, fn($q, $term) =>
|
||||||
|
$q->where('name', 'like', "%{$term}%")
|
||||||
|
)
|
||||||
|
->paginate(10);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpiar caché al modificar productos
|
||||||
|
Cache::forget('products.*');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Eager Loading para Relaciones
|
||||||
|
```php
|
||||||
|
// Evitar N+1 queries
|
||||||
|
$sales = Sale::with(['client', 'details.product'])
|
||||||
|
->latest()
|
||||||
|
->paginate(15);
|
||||||
|
|
||||||
|
// En lugar de:
|
||||||
|
$sales = Sale::all(); // Genera queries adicionales
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Índices de Base de Datos
|
||||||
|
```php
|
||||||
|
// En migraciones
|
||||||
|
Schema::table('products', function (Blueprint $table) {
|
||||||
|
$table->index('sku');
|
||||||
|
$table->index('type');
|
||||||
|
$table->index(['stock_quantity', 'min_stock_alert']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('sales', function (Blueprint $table) {
|
||||||
|
$table->index('created_at');
|
||||||
|
$table->index(['client_id', 'created_at']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Paginación con Cursor
|
||||||
|
```php
|
||||||
|
// Para datasets grandes
|
||||||
|
$products = Product::latest()->cursorPaginate(20);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refactorización de Código
|
||||||
|
|
||||||
|
#### 1. Services para Lógica de Negocio
|
||||||
|
```php
|
||||||
|
// app/Services/SaleService.php
|
||||||
|
class SaleService
|
||||||
|
{
|
||||||
|
public function processSale(array $data): Sale
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($data) {
|
||||||
|
$sale = Sale::create([...]);
|
||||||
|
|
||||||
|
foreach ($data['items'] as $item) {
|
||||||
|
$this->addItem($sale, $item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sale;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addItem(Sale $sale, array $item): void
|
||||||
|
{
|
||||||
|
$product = Product::findOrFail($item['product_id']);
|
||||||
|
|
||||||
|
if ($product->stock_quantity < $item['quantity']) {
|
||||||
|
throw new InsufficientStockException();
|
||||||
|
}
|
||||||
|
|
||||||
|
SaleDetail::create([...]);
|
||||||
|
$product->decrement('stock_quantity', $item['quantity']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Form Request Validation
|
||||||
|
```php
|
||||||
|
// app/Http/Requests/StoreSaleRequest.php
|
||||||
|
class StoreSaleRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'client_id' => 'nullable|exists:clients,id',
|
||||||
|
'payment_method' => 'required|string',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.product_id' => 'required|exists:products,id',
|
||||||
|
'items.*.quantity' => 'required|integer|min:1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'items.required' => 'Debe agregar al menos un producto.',
|
||||||
|
'items.*.quantity.min' => 'La cantidad debe ser mayor a 0.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En el controlador
|
||||||
|
public function store(StoreSaleRequest $request)
|
||||||
|
{
|
||||||
|
// $request ya está validado
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Repository Pattern
|
||||||
|
```php
|
||||||
|
// app/Repositories/ProductRepository.php
|
||||||
|
interface ProductRepositoryInterface
|
||||||
|
{
|
||||||
|
public function search(string $term): Collection;
|
||||||
|
public function lowStock(): Collection;
|
||||||
|
public function byType(string $type): Collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProductRepository implements ProductRepositoryInterface
|
||||||
|
{
|
||||||
|
public function search(string $term): Collection
|
||||||
|
{
|
||||||
|
return Product::where('name', 'like', "%{$term}%")
|
||||||
|
->orWhere('sku', 'like', "%{$term}%")
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar en AppServiceProvider
|
||||||
|
$this->app->bind(
|
||||||
|
ProductRepositoryInterface::class,
|
||||||
|
ProductRepository::class
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Policies para Autorización
|
||||||
|
```php
|
||||||
|
// app/Policies/ProductPolicy.php
|
||||||
|
class ProductPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return in_array($user->role, ['admin', 'employee']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Product $product): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Product $product): bool
|
||||||
|
{
|
||||||
|
return $user->role === 'admin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En controlador
|
||||||
|
$this->authorize('create', Product::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mejoras de Seguridad
|
||||||
|
|
||||||
|
#### 1. Rate Limiting Específico
|
||||||
|
```php
|
||||||
|
// routes/web.php
|
||||||
|
Route::post('/sales', [SaleController::class, 'store'])
|
||||||
|
->middleware(['auth', 'throttle:sales']);
|
||||||
|
|
||||||
|
// app/Providers/RouteServiceProvider.php
|
||||||
|
RateLimiter::for('sales', function (Request $request) {
|
||||||
|
return Limit::perMinute(10)->by($request->user()?->id);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Sanitización de Inputs
|
||||||
|
```php
|
||||||
|
// En Form Request
|
||||||
|
protected function prepareForValidation()
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'name' => strip_tags($this->name),
|
||||||
|
'description' => Purifier::clean($this->description),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Protección CSRF Explícita
|
||||||
|
```php
|
||||||
|
// Verificar en AJAX
|
||||||
|
$.ajaxSetup({
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Validación de Imágenes
|
||||||
|
```php
|
||||||
|
'image' => [
|
||||||
|
'nullable',
|
||||||
|
'image',
|
||||||
|
'mimes:jpeg,png,jpg,webp',
|
||||||
|
'max:2048', // 2MB
|
||||||
|
'dimensions:min_width=100,min_height=100,max_width=2000,max_height=2000'
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mejoras de UI/UX
|
||||||
|
|
||||||
|
#### 1. Loading States
|
||||||
|
```blade
|
||||||
|
<button type="submit" id="submit-btn" class="...">
|
||||||
|
<span class="loading hidden">
|
||||||
|
<svg class="animate-spin h-5 w-5" ...></svg>
|
||||||
|
</span>
|
||||||
|
<span class="text">Guardar</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$('#form').on('submit', function() {
|
||||||
|
$('#submit-btn .loading').removeClass('hidden');
|
||||||
|
$('#submit-btn .text').addClass('hidden');
|
||||||
|
$('#submit-btn').prop('disabled', true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Confirmaciones Elegantes
|
||||||
|
```javascript
|
||||||
|
// Usar SweetAlert2
|
||||||
|
Swal.fire({
|
||||||
|
title: '¿Eliminar producto?',
|
||||||
|
text: "Esta acción no se puede revertir",
|
||||||
|
icon: 'warning',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonColor: '#ccff00',
|
||||||
|
cancelButtonColor: '#d33',
|
||||||
|
confirmButtonText: 'Sí, eliminar',
|
||||||
|
cancelButtonText: 'Cancelar'
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
// Ejecutar eliminación
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Toast Notifications
|
||||||
|
```javascript
|
||||||
|
// Mensajes no intrusivos
|
||||||
|
const Toast = Swal.mixin({
|
||||||
|
toast: true,
|
||||||
|
position: 'top-end',
|
||||||
|
showConfirmButton: false,
|
||||||
|
timer: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.fire({
|
||||||
|
icon: 'success',
|
||||||
|
title: 'Producto guardado'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Validación en Tiempo Real
|
||||||
|
```javascript
|
||||||
|
$('input[name="sku"]').on('blur', function() {
|
||||||
|
const sku = $(this).val();
|
||||||
|
|
||||||
|
$.get(`/api/products/check-sku/${sku}`, function(data) {
|
||||||
|
if (data.exists) {
|
||||||
|
// Mostrar error
|
||||||
|
$(this).addClass('border-red-500');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
#### 1. Tests de Feature
|
||||||
|
```php
|
||||||
|
// tests/Feature/ProductTest.php
|
||||||
|
public function test_admin_can_create_product()
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post('/productos', [
|
||||||
|
'name' => 'Test Product',
|
||||||
|
'price' => 100,
|
||||||
|
'stock_quantity' => 10,
|
||||||
|
'min_stock_alert' => 5,
|
||||||
|
'type' => 'accessory'
|
||||||
|
])
|
||||||
|
->assertRedirect('/productos')
|
||||||
|
->assertSessionHas('success');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('products', [
|
||||||
|
'name' => 'Test Product'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_guest_cannot_create_product()
|
||||||
|
{
|
||||||
|
$response = $this->post('/productos', []);
|
||||||
|
$response->assertRedirect('/login');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Tests Unitarios
|
||||||
|
```php
|
||||||
|
// tests/Unit/ProductTest.php
|
||||||
|
public function test_product_has_low_stock()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create([
|
||||||
|
'stock_quantity' => 3,
|
||||||
|
'min_stock_alert' => 5
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($product->hasLowStock());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Browser Tests (Dusk)
|
||||||
|
```php
|
||||||
|
// tests/Browser/PosTest.php
|
||||||
|
public function test_can_complete_sale()
|
||||||
|
{
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
|
$browser->loginAs(User::find(1))
|
||||||
|
->visit('/sales/create')
|
||||||
|
->select('items[0][product_id]', 1)
|
||||||
|
->type('items[0][quantity]', 2)
|
||||||
|
->press('Confirmar Venta')
|
||||||
|
->assertPathIs('/sales/1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user