This commit is contained in:
Lizandro Guarnizo
2026-01-06 15:35:59 -05:00
commit a768146f65
602 changed files with 42505 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
created:
- database/factories/AuthUserFactory.php
- database/migrations/2025_03_06_023837_create_auth_users_table.php
- app/Models/AuthUser.php
models:
User: { name: 'string:100', email: 'string:150 unique', password: string, timestamps: timestamps, relationships: { belongsToMany: Role } }
Role: { name: 'string:50 unique', description: 'string nullable', timestamps: timestamps, relationships: { belongsToMany: User } }
Setting: { logo: 'string nullable', description: 'text nullable', primary_color: 'string nullable', secondary_color: 'string nullable', created_at: 'timestamp nullable', updated_at: 'timestamp nullable' }
AuthUser: { id: id, username: string, password: string, last_login: timestamp, is_superuser: boolean, first_name: string, last_name: string, email: string, is_staff: boolean, is_active: boolean, date_joined: timestamp, timestamps: false }
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4
+65
View File
@@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+23
View File
@@ -0,0 +1,23 @@
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.zed
+66
View File
@@ -0,0 +1,66 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com/)**
- **[Tighten Co.](https://tighten.co)**
- **[WebReinvent](https://webreinvent.com/)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
- **[Cyber-Duck](https://cyber-duck.co.uk)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Jump24](https://jump24.co.uk)**
- **[Redberry](https://redberry.international/laravel/)**
- **[Active Logic](https://activelogic.com)**
- **[byte5](https://byte5.de)**
- **[OP.GG](https://op.gg)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use App\Mail\ReporteVentasMail;
use App\Models\User;
use App\Models\Venta;
use Carbon\Carbon;
class EnviarReporteVentas extends Command
{
protected $signature = 'reporte:ventas';
protected $description = 'Enviar reporte de ventas semanal por correo';
public function handle()
{
$fromDate = Carbon::now()->subWeek()->startOfWeek(); // Lunes anterior
$toDate = Carbon::now()->subWeek()->endOfWeek(); // Domingo anterior
$ventas = Venta::with(['cliente', 'detalles.producto', 'detalles.variante'])
->whereBetween('created_at', [$fromDate, $toDate])
->get();
// Obtener todos los usuarios con el rol 'Administrador'
$administradores = User::role('Administrador')->get();
if ($administradores->isEmpty()) {
$this->warn('No se encontraron usuarios con el rol Administrador.');
return;
}
foreach ($administradores as $admin) {
Mail::to($admin->email)->send(new ReporteVentasMail($ventas, $fromDate, $toDate, $admin->email));
$this->info("Reporte enviado a: {$admin->email}");
}
$this->info('Todos los reportes han sido enviados a los administradores.');
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Bodega;
use App\Models\Producto;
use Illuminate\Support\Facades\DB;
class MigrarStockBodegaPrincipal extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'stock:migrar-principal';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migra todo el stock actual de productos a la bodega principal';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Iniciando migración de stock a bodega principal...');
DB::beginTransaction();
try {
// 1. Crear o encontrar la bodega principal
$bodegaPrincipal = Bodega::firstOrCreate([
'nombre' => 'Principal'
]);
$this->info("Bodega principal creada/encontrada: {$bodegaPrincipal->nombre}");
// 2. Obtener todos los productos que necesitan migración
$productos = Producto::with('variants')->get()->filter(function ($producto) {
// Productos con stock directo O con variantes que tienen stock
return $producto->stock > 0 ||
($producto->variants()->exists() && $producto->variants()->sum('stock') > 0);
});
$this->info("Encontrados {$productos->count()} productos con stock para migrar");
$productosActualizados = 0;
foreach ($productos as $producto) {
// Calcular stock total a migrar
$stockAMigrar = 0;
if ($producto->variants()->exists()) {
// Para productos con variantes, usar la suma de variantes
$stockAMigrar = $producto->variants()->sum('stock');
$this->line(" → Producto con variantes: {$producto->nombre}");
$this->line(" Stock total de variantes: {$stockAMigrar}");
} else {
// Para productos sin variantes, usar stock directo
$stockAMigrar = $producto->stock;
}
if ($stockAMigrar <= 0) {
continue; // Saltar productos sin stock
}
// Verificar si ya existe una relación con esta bodega
$existeRelacion = $producto->bodegas()
->where('bodega_id', $bodegaPrincipal->id)
->exists();
if (!$existeRelacion) {
// Crear la relación con el stock calculado
$producto->bodegas()->attach($bodegaPrincipal->id, [
'stock' => $stockAMigrar
]);
$this->line("{$producto->nombre}: {$stockAMigrar} unidades → Bodega Principal");
$productosActualizados++;
} else {
// Actualizar el stock existente sumando el stock calculado
$stockActual = $producto->bodegas()
->where('bodega_id', $bodegaPrincipal->id)
->first()->pivot->stock;
$nuevoStock = $stockActual + $stockAMigrar;
$producto->bodegas()->updateExistingPivot($bodegaPrincipal->id, [
'stock' => $nuevoStock
]);
$this->line("{$producto->nombre}: {$stockActual} + {$stockAMigrar} = {$nuevoStock} unidades → Bodega Principal");
$productosActualizados++;
}
}
DB::commit();
$this->info("\n🎉 Migración completada exitosamente!");
$this->info("📦 Productos procesados: {$productosActualizados}");
$this->info("🏢 Bodega principal: {$bodegaPrincipal->nombre}");
} catch (\Exception $e) {
DB::rollBack();
$this->error("❌ Error durante la migración: " . $e->getMessage());
return Command::FAILURE;
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,144 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Bodega;
use App\Models\ProductVariant;
use App\Models\Producto;
use Illuminate\Support\Facades\DB;
class MigrarVariantesBodegaPrincipal extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'stock:migrar-todo-principal';
/**
* The console command description.
*/
protected $description = 'Migra el stock de productos y variantes a la bodega principal';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('🚀 Iniciando migración completa de stock a bodega principal...');
DB::beginTransaction();
try {
// 1. Crear o encontrar la bodega principal
$bodegaPrincipal = Bodega::firstOrCreate([
'nombre' => 'Principal'
], [
'descripcion' => 'Bodega principal del sistema',
'ubicacion' => 'Sede principal',
'estado' => true
]);
$this->info("✅ Bodega principal: {$bodegaPrincipal->nombre}");
// 2. MIGRAR PRODUCTOS DIRECTOS (sin variantes)
$this->info("\n📦 Migrando productos directos...");
$productosDirectos = Producto::where('estado', true)
->where('stock', '>', 0)
->whereDoesntHave('variants') // Solo productos sin variantes
->get();
$this->info("Encontrados {$productosDirectos->count()} productos directos con stock");
$productosActualizados = 0;
foreach ($productosDirectos as $producto) {
// Verificar si ya existe una relación con esta bodega
$existeRelacion = $producto->bodegas()
->where('bodega_id', $bodegaPrincipal->id)
->exists();
if (!$existeRelacion) {
// Crear la relación con el stock actual del producto
$producto->bodegas()->attach($bodegaPrincipal->id, [
'stock' => $producto->stock
]);
$this->line("{$producto->nombre}: {$producto->stock} unidades → Bodega Principal");
$productosActualizados++;
} else {
// Actualizar el stock existente
$stockActual = $producto->bodegas()
->where('bodega_id', $bodegaPrincipal->id)
->first()->pivot->stock;
$nuevoStock = $stockActual + $producto->stock;
$producto->bodegas()->updateExistingPivot($bodegaPrincipal->id, [
'stock' => $nuevoStock
]);
$this->line("{$producto->nombre}: {$stockActual} + {$producto->stock} = {$nuevoStock} unidades → Bodega Principal");
$productosActualizados++;
}
}
// 3. MIGRAR VARIANTES DE PRODUCTOS
$this->info("\n🔄 Migrando variantes de productos...");
$variantes = ProductVariant::with('producto')
->where('stock', '>', 0)
->get();
$this->info("Encontradas {$variantes->count()} variantes con stock");
$variantesActualizadas = 0;
foreach ($variantes as $variante) {
// Verificar si ya existe una relación con esta bodega
$existeRelacion = $variante->bodegas()
->where('bodega_id', $bodegaPrincipal->id)
->exists();
if (!$existeRelacion) {
// Crear la relación con el stock actual de la variante
$variante->bodegas()->attach($bodegaPrincipal->id, [
'stock' => $variante->stock
]);
$this->line("{$variante->producto->nombre} [{$variante->sku}]: {$variante->stock} unidades → Bodega Principal");
$variantesActualizadas++;
} else {
// Actualizar el stock existente sumando el stock de la variante
$stockActual = $variante->bodegas()
->where('bodega_id', $bodegaPrincipal->id)
->first()->pivot->stock;
$nuevoStock = $stockActual + $variante->stock;
$variante->bodegas()->updateExistingPivot($bodegaPrincipal->id, [
'stock' => $nuevoStock
]);
$this->line("{$variante->producto->nombre} [{$variante->sku}]: {$stockActual} + {$variante->stock} = {$nuevoStock} unidades → Bodega Principal");
$variantesActualizadas++;
}
}
DB::commit();
$this->info("\n🎉 Migración completa de stock exitosa!");
$this->info("📦 Productos directos procesados: {$productosActualizados}");
$this->info(" Variantes procesadas: {$variantesActualizadas}");
$this->info("🏢 Bodega principal: {$bodegaPrincipal->nombre}");
$this->info("📊 Total de elementos migrados: " . ($productosActualizados + $variantesActualizadas));
} catch (\Exception $e) {
DB::rollBack();
$this->error("❌ Error durante la migración: " . $e->getMessage());
$this->error("🔍 Línea: " . $e->getLine());
return Command::FAILURE;
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use App\Mail\AlertaStockMail;
use App\Models\User;
use App\Models\Producto;
class VerificarAlertasStock extends Command
{
protected $signature = 'stock:verificar-alertas {--email= : Email específico para enviar la alerta}';
protected $description = 'Verificar niveles de stock y enviar alertas por correo cuando se alcance el mínimo o máximo';
public function handle()
{
$this->info('🔍 Iniciando verificación de niveles de stock...');
// Obtener productos con stock mínimo o inferior
$productosStockMinimo = $this->obtenerProductosStockMinimo();
// Obtener productos que exceden el stock máximo
$productosStockMaximo = $this->obtenerProductosStockMaximo();
if ($productosStockMinimo->isEmpty() && $productosStockMaximo->isEmpty()) {
$this->info('✅ Todos los productos están en niveles normales de stock.');
return Command::SUCCESS;
}
// Mostrar resumen en consola
$this->mostrarResumen($productosStockMinimo, $productosStockMaximo);
// Enviar alertas por correo
$this->enviarAlertas($productosStockMinimo, $productosStockMaximo);
return Command::SUCCESS;
}
private function obtenerProductosStockMinimo()
{
return Producto::with(['categoria', 'variants', 'bodegas'])
->where('estado', true)
->get()
->filter(function ($producto) {
// Usar el método getStockEfectivo que considera bodegas
$stockActual = $producto->getStockEfectivo();
return $stockActual <= $producto->stock_minimo;
});
}
private function obtenerProductosStockMaximo()
{
return Producto::with(['categoria', 'variants', 'bodegas'])
->where('estado', true)
->whereNotNull('stock_maximo')
->get()
->filter(function ($producto) {
// Usar el método getStockEfectivo que considera bodegas
$stockActual = $producto->getStockEfectivo();
return $stockActual > $producto->stock_maximo;
});
}
private function mostrarResumen($productosStockMinimo, $productosStockMaximo)
{
if ($productosStockMinimo->isNotEmpty()) {
$this->warn("⚠️ {$productosStockMinimo->count()} productos con stock mínimo o inferior:");
$headers = ['Producto', 'Stock Actual', 'Stock Mínimo', 'Estado'];
$rows = [];
foreach ($productosStockMinimo as $producto) {
$stockActual = $producto->getStockEfectivo();
$estado = $stockActual == 0 ? 'Sin Stock' : ($stockActual < $producto->stock_minimo ? 'Por debajo' : 'En mínimo');
$rows[] = [
$producto->nombre,
$stockActual,
$producto->stock_minimo,
$estado
];
}
$this->table($headers, $rows);
}
if ($productosStockMaximo->isNotEmpty()) {
$this->info("📈 {$productosStockMaximo->count()} productos que exceden el stock máximo:");
$headers = ['Producto', 'Stock Actual', 'Stock Máximo', 'Exceso'];
$rows = [];
foreach ($productosStockMaximo as $producto) {
$stockActual = $producto->getStockEfectivo();
$exceso = $stockActual - $producto->stock_maximo;
$rows[] = [
$producto->nombre,
$stockActual,
$producto->stock_maximo,
"+{$exceso}"
];
}
$this->table($headers, $rows);
}
}
private function enviarAlertas($productosStockMinimo, $productosStockMaximo)
{
// Determinar destinatarios
$email = $this->option('email');
if ($email) {
$destinatarios = collect([$email]);
$this->info("📧 Enviando alerta a email específico: {$email}");
} else {
// Obtener administradores
$administradores = User::role('Administrador')->get();
if ($administradores->isEmpty()) {
$this->warn('⚠️ No se encontraron usuarios con el rol Administrador.');
return;
}
$destinatarios = $administradores->pluck('email');
$this->info("📧 Enviando alertas a {$administradores->count()} administradores...");
}
// Enviar correos
foreach ($destinatarios as $emailDestino) {
try {
Mail::to($emailDestino)->send(new AlertaStockMail(
$productosStockMinimo,
$productosStockMaximo,
$emailDestino
));
$this->info("✅ Alerta enviada a: {$emailDestino}");
} catch (\Exception $e) {
$this->error("❌ Error al enviar a {$emailDestino}: " . $e->getMessage());
}
}
$this->info('🎉 Proceso de envío de alertas completado.');
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Exports;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithColumnWidths;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Maatwebsite\Excel\Concerns\WithTitle;
class PlantillaCompraExport implements FromArray, WithHeadings, WithStyles, WithColumnWidths, WithTitle
{
/**
* Datos de ejemplo para la plantilla
*/
public function array(): array
{
return [
['7501234567890', 'Balde 15 L', 10, 50000, 'Principal', 'Si codigo existe, usa ese producto'],
['', 'Producto Nuevo Sin Codigo', 5, 75000, 'Principal', 'Si no hay codigo, busca por nombre'],
['9999999999999', 'Escoba Verde', 20, 25000, 'Principal', 'Codigo nuevo - crea producto con ese nombre'],
['', '', '', '', '', ''],
['', 'INSTRUCCIONES:', '', '', '', ''],
['', '1. Si tiene codigo: busca por codigo primero', '', '', '', ''],
['', '2. Si no hay codigo: busca por nombre', '', '', '', ''],
['', '3. Si no existe: crea nuevo producto', '', '', '', ''],
];
}
/**
* Encabezados de las columnas
*/
public function headings(): array
{
return [
'Codigo de Barras',
'Producto',
'Cantidad',
'Precio Unitario',
'Bodega',
'Observaciones',
];
}
/**
* Estilos de la hoja
*/
public function styles(Worksheet $sheet)
{
return [
// Estilo para la fila de encabezados
1 => [
'font' => [
'bold' => true,
'size' => 12,
'color' => ['rgb' => 'FFFFFF'],
],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => '4472C4'],
],
'alignment' => [
'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
'vertical' => \PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER,
],
],
// Filas de datos
'2:8' => [
'alignment' => [
'vertical' => \PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER,
],
],
// Filas de instrucciones
'5:8' => [
'font' => [
'italic' => true,
'color' => ['rgb' => '666666'],
],
],
];
}
/**
* Ancho de las columnas
*/
public function columnWidths(): array
{
return [
'A' => 20, // Codigo de Barras
'B' => 25, // Producto
'C' => 12, // Cantidad
'D' => 18, // Precio Unitario
'E' => 15, // Bodega
'F' => 35, // Observaciones
];
}
/**
* Título de la hoja
*/
public function title(): string
{
return 'Plantilla Compras';
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
namespace App\Exports;
use App\Models\Producto;
use App\Models\Categoria;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
class ProductosExport implements WithMultipleSheets
{
/**
* Retorna las hojas del Excel
*/
public function sheets(): array
{
return [
new ProductosSheet(),
new CategoriasReferenciaSheet(),
];
}
}
/**
* Hoja de productos
*/
class ProductosSheet implements
\Maatwebsite\Excel\Concerns\FromCollection,
\Maatwebsite\Excel\Concerns\WithHeadings,
\Maatwebsite\Excel\Concerns\WithMapping,
\Maatwebsite\Excel\Concerns\WithStyles,
\Maatwebsite\Excel\Concerns\ShouldAutoSize,
\Maatwebsite\Excel\Concerns\WithTitle
{
/**
* Retorna la colección de productos a exportar
*/
public function collection()
{
return Producto::with('categoria')->get();
}
/**
* Define los encabezados del Excel
*/
public function headings(): array
{
return [
'ID',
'Nombre',
'Descripcion',
'Codigo_de_Barras',
'Categoria',
'Precio_Compra',
'Precio_Venta',
'Stock',
'Stock_Minimo',
'Stock_Maximo',
'Unidad_de_Medida',
'Estado',
];
}
/**
* Mapea cada producto a una fila del Excel
*/
public function map($producto): array
{
return [
$producto->id,
$producto->nombre,
$producto->descripcion,
$producto->codigo_barras,
$producto->categoria?->nombre ?? '',
$producto->precio_compra,
$producto->precio_venta,
$producto->getStockEfectivo(),
$producto->stock_minimo,
$producto->stock_maximo,
$producto->unidad_medida ?? 'unidad',
$producto->estado ? 'Activo' : 'Inactivo',
];
}
/**
* Aplica estilos al Excel
*/
public function styles(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet)
{
return [
// Estilo para la fila de encabezados
1 => [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => '4F46E5']
],
'font' => [
'bold' => true,
'color' => ['rgb' => 'FFFFFF']
]
],
];
}
/**
* Título de la hoja
*/
public function title(): string
{
return 'Productos';
}
}
/**
* Hoja de referencia de categorías
*/
class CategoriasReferenciaSheet implements
\Maatwebsite\Excel\Concerns\FromCollection,
\Maatwebsite\Excel\Concerns\WithHeadings,
\Maatwebsite\Excel\Concerns\WithMapping,
\Maatwebsite\Excel\Concerns\WithStyles,
\Maatwebsite\Excel\Concerns\ShouldAutoSize,
\Maatwebsite\Excel\Concerns\WithTitle
{
/**
* Retorna la colección de categorías
*/
public function collection()
{
return Categoria::all();
}
/**
* Define los encabezados
*/
public function headings(): array
{
return [
'ID',
'Nombre_de_Categoria',
'Descripcion',
];
}
/**
* Mapea cada categoría a una fila
*/
public function map($categoria): array
{
return [
$categoria->id,
$categoria->nombre,
'Usa este nombre en la columna "Categoria" de la hoja Productos',
];
}
/**
* Aplica estilos
*/
public function styles(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet)
{
return [
1 => [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => '10B981']
],
'font' => [
'bold' => true,
'color' => ['rgb' => 'FFFFFF']
]
],
];
}
/**
* Título de la hoja
*/
public function title(): string
{
return 'Categorías Disponibles';
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace App\Filament\Pages;
use App\Models\Producto;
use App\Models\Bodega;
use Filament\Pages\Page;
use App\Services\TransferenciaBodegaService;
use Filament\Notifications\Notification;
class GestionStockPage extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-chart-bar-square';
protected static ?string $navigationLabel = 'Gestión de Stock';
protected static ?string $navigationGroup = 'Inventario';
protected static string $view = 'filament.pages.gestion-stock';
public $productosStockBajo;
public $bodegasEstado;
public $sugerenciasTransferencia;
public function mount(): void
{
// Datos para la vista
$this->productosStockBajo = $this->getProductosStockBajo();
$this->bodegasEstado = $this->getBodegasEstado();
$this->sugerenciasTransferencia = $this->getSugerenciasTransferencia();
}
protected function getProductosStockBajo()
{
return Producto::with(['bodegas', 'categoria'])
->where('estado', true)
->get()
->filter(function ($producto) {
return $producto->getStockEfectivo() <= $producto->stock_minimo;
})
->map(function ($producto) {
return [
'id' => $producto->id,
'nombre' => $producto->nombre,
'categoria' => $producto->categoria->nombre ?? 'Sin categoría',
'stock_actual' => $producto->getStockEfectivo(),
'stock_minimo' => $producto->stock_minimo,
'distribucion' => $producto->bodegas->map(function ($bodega) {
return [
'bodega' => $bodega->nombre,
'stock' => $bodega->pivot->stock,
];
})->toArray(),
];
})
->toArray();
}
protected function getBodegasEstado()
{
return Bodega::with('productos')
->get()
->map(function ($bodega) {
$totalProductos = $bodega->productos()->count();
$productosConStock = $bodega->productos()->wherePivot('stock', '>', 0)->count();
$stockTotal = $bodega->productos()->sum('producto_bodega.stock');
return [
'id' => $bodega->id,
'nombre' => $bodega->nombre,
'total_productos' => $totalProductos,
'productos_con_stock' => $productosConStock,
'stock_total' => $stockTotal,
'utilizacion' => $totalProductos > 0 ? round(($productosConStock / $totalProductos) * 100, 1) : 0,
];
})
->toArray();
}
protected function getSugerenciasTransferencia()
{
$sugerencias = [];
// Buscar productos con stock desbalanceado entre bodegas
$productos = Producto::with('bodegas')
->where('estado', true)
->get();
foreach ($productos as $producto) {
if ($producto->bodegas->count() > 1) {
$bodegas = $producto->bodegas->sortByDesc('pivot.stock');
$mayor = $bodegas->first();
$menor = $bodegas->last();
// Si hay gran diferencia de stock entre bodegas
if ($mayor->pivot->stock > 0 && $menor->pivot->stock <= $producto->stock_minimo) {
$cantidadSugerida = min(
floor($mayor->pivot->stock * 0.3), // Máximo 30% del stock de la bodega con más stock
$producto->stock_minimo - $menor->pivot->stock + 5 // Lo necesario para estar sobre el mínimo
);
if ($cantidadSugerida > 0) {
$sugerencias[] = [
'producto_id' => $producto->id,
'producto_nombre' => $producto->nombre,
'bodega_origen' => $mayor->nombre,
'bodega_origen_id' => $mayor->id,
'bodega_destino' => $menor->nombre,
'bodega_destino_id' => $menor->id,
'cantidad_sugerida' => $cantidadSugerida,
'razon' => "Rebalanceo: {$menor->nombre} bajo mínimo",
'stock_origen' => $mayor->pivot->stock,
'stock_destino' => $menor->pivot->stock,
];
}
}
}
}
return collect($sugerencias)->take(10)->toArray();
}
public function ejecutarTransferenciaSugerida($data)
{
try {
$service = new TransferenciaBodegaService();
$transferencia = $service->transferir(
$data['producto_id'],
$data['bodega_origen_id'],
$data['bodega_destino_id'],
$data['cantidad_sugerida'],
$data['razon'] . ' (Transferencia automática sugerida)'
);
Notification::make()
->title('Transferencia ejecutada')
->body("Se transfirieron {$data['cantidad_sugerida']} unidades de {$data['producto_nombre']}")
->success()
->send();
// Recargar datos
$this->mount();
} catch (\Exception $e) {
Notification::make()
->title('Error en transferencia')
->body($e->getMessage())
->danger()
->send();
}
}
}
+480
View File
@@ -0,0 +1,480 @@
<?php
namespace App\Filament\Pages;
use App\Models\Caja;
use App\Models\Cliente;
use App\Models\MovimientoCaja;
use Filament\Pages\Page;
use Filament\Forms;
use Filament\Tables;
use App\Models\Producto;
use App\Models\ProductVariant;
use App\Models\Venta;
use Livewire\Component;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class PuntoVenta extends Page
{
protected static ?string $navigationGroup = 'Operación';
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';
protected static string $view = 'filament.pages.punto-venta';
public $barcodeBusqueda = '';
public $productos = [];
public $carrito = [];
public $total = 0;
public $tipo_pago = 'Efectivo';
public $numeroDocumentoCliente = '';
public $datosCliente = [
'nombre' => null,
'correo' => null,
'telefono' => null,
];
public $cliente_id = null;
public $correoCotizacion = '';
public $bodega_id = null; // Bodega seleccionada para la venta
public function mount()
{
$this->productos = Producto::where('estado', true)
->where(function ($query) {
$query->where('stock', '>', 0)
->orWhereHas('bodegas', function ($q) {
$q->where('producto_bodega.stock', '>', 0);
})
->orWhereHas('variants', function ($q) {
$q->where('stock', '>', 0)
->orWhereHas('bodegas', function ($bq) {
$bq->where('variante_bodega.stock', '>', 0);
});
});
})
->with('variants.color', 'variants.size', 'bodegas')
->limit(20)
->get();
// Seleccionar bodega "Principal" por defecto
$bodegaPrincipal = \App\Models\Bodega::where('nombre', 'Principal')->first();
$this->bodega_id = $bodegaPrincipal ? $bodegaPrincipal->id : null;
}
public function agregarAlCarrito($productoId, $tipo)
{
if ($tipo == 'p') {
$producto = Producto::find($productoId);
if (!$producto)
return;
$key = 'p-' . $producto->id;
if (!isset($this->carrito[$key])) {
$this->carrito[$key] = [
'id' => $producto->id,
'nombre' => $producto->nombre,
'precio_venta' => $producto->precio_venta,
'precio_original' => $producto->precio_venta,
'precio_modificado' => $producto->precio_venta,
'cantidad' => 1,
'tipo' => 'p',
];
} else {
$this->carrito[$key]['cantidad']++;
}
} else {
$producto = ProductVariant::find($productoId);
if (!$producto)
return;
$key = 'v-' . $producto->id;
if (!isset($this->carrito[$key])) {
$this->carrito[$key] = [
'id' => $producto->producto->id,
'nombre' => $producto->producto->nombre,
'precio_venta' => $producto->producto->precio_venta,
'precio_original' => $producto->producto->precio_venta,
'precio_modificado' => $producto->producto->precio_venta,
'variante' => 'Color: ' . $producto->color->name . ', Talla: ' . $producto->size->name,
'variante_id' => $producto->id,
'cantidad' => 1,
'tipo' => 'v',
];
} else {
$this->carrito[$key]['cantidad']++;
}
}
$this->calcularTotal();
}
public function calcularTotal()
{
$this->total = collect($this->carrito)->sum(fn($item) => $item['precio_modificado'] * $item['cantidad']);
}
public function actualizarPrecio($key, $nuevoPrecio)
{
if (isset($this->carrito[$key])) {
// Validar que el precio sea válido (mayor o igual a 0)
$precio = floatval($nuevoPrecio);
if ($precio >= 0) {
$this->carrito[$key]['precio_modificado'] = $precio;
$this->calcularTotal();
}
}
}
public function procesarVenta()
{
if (empty($this->carrito)) {
session()->flash('error', 'No se puede procesar la venta: el carrito está vacío.');
return;
}
DB::beginTransaction();
try {
$clienteId = null;
if ($this->numeroDocumentoCliente) {
$cliente = Cliente::where('numero_documento', $this->numeroDocumentoCliente)->first();
if (!$cliente) {
$datosCliente = [
'nombre' => $this->datosCliente['nombre'] ?? "Cliente General",
'correo' => $this->datosCliente['correo'] ?? null,
'telefono' => $this->datosCliente['telefono'] ?? null,
'numero_documento' => $this->numeroDocumentoCliente,
'tipo_documento' => $this->datosCliente['tipo_documento'] ?? 'CC',
];
$cliente = Cliente::create($datosCliente);
}
$clienteId = $cliente->id;
} elseif ($this->cliente_id) {
$cliente = Cliente::find($this->cliente_id);
if ($cliente) {
$clienteId = $cliente->id;
} else {
$this->cliente_id = null;
$clienteId = null;
}
}
$venta = Venta::create([
'cliente_id' => $clienteId,
'total' => $this->total,
'tipo_pago' => $this->tipo_pago,
'estado' => 'Pagado',
]);
foreach ($this->carrito as $item) {
$precioOriginal = $item['precio_original'];
$precioModificado = $item['precio_modificado'];
$descuentoAplicado = $precioOriginal - $precioModificado;
$cantidadPendiente = $item['cantidad'];
// Identificar el producto o variante real
$producto = null;
$variante = null;
if (isset($item['variante_id'])) {
$variante = ProductVariant::find($item['variante_id']);
$producto = $variante->producto;
} else {
$producto = Producto::find($item['id']);
}
// Estrategia de deducción de stock:
// 1. Buscar en Bodega Principal
// 2. Buscar en otras bodegas con stock
// 3. Si falta, descontar de stock directo (o dejar negativo en Principal si se prefiere, aquí usaremos Principal como fallback)
$bodegas = \App\Models\Bodega::orderByRaw("CASE WHEN nombre = 'Principal' THEN 0 ELSE 1 END")->get();
foreach ($bodegas as $bodega) {
if ($cantidadPendiente <= 0) break;
$stockDisponible = 0;
if ($variante) {
$bodegaVariante = $variante->bodegas()->where('bodega_id', $bodega->id)->first();
$stockDisponible = $bodegaVariante ? $bodegaVariante->pivot->stock : 0;
} else {
$bodegaProducto = $producto->bodegas()->where('bodega_id', $bodega->id)->first();
$stockDisponible = $bodegaProducto ? $bodegaProducto->pivot->stock : 0;
}
if ($stockDisponible > 0) {
$cantidadADescontar = min($cantidadPendiente, $stockDisponible);
// Registrar detalle para esta bodega
$venta->detalles()->create([
'producto_id' => $item['id'],
'variante_id' => $item['variante_id'] ?? null,
'bodega_id' => $bodega->id,
'cantidad' => $cantidadADescontar,
'precio_unitario' => $precioModificado,
'precio_original' => $precioOriginal,
'descuento_aplicado' => $descuentoAplicado,
'usuario_modifico_precio_id' => ($descuentoAplicado != 0) ? auth()->id() : null,
'subtotal' => $cantidadADescontar * $precioModificado,
]);
// Actualizar stock
if ($variante) {
$variante->bodegas()->updateExistingPivot($bodega->id, [
'stock' => $stockDisponible - $cantidadADescontar
]);
} else {
$producto->bodegas()->updateExistingPivot($bodega->id, [
'stock' => $stockDisponible - $cantidadADescontar
]);
}
$cantidadPendiente -= $cantidadADescontar;
}
}
// Si aún queda cantidad pendiente (no había stock suficiente en ninguna bodega)
// Lo asignamos a la Bodega Principal (o NULL si no hay bodegas) y dejamos que el stock se vaya a negativo o se descuente del directo
if ($cantidadPendiente > 0) {
$bodegaFallback = $bodegas->first(); // Principal por el ordenamiento
$bodegaIdFallback = $bodegaFallback ? $bodegaFallback->id : null;
$venta->detalles()->create([
'producto_id' => $item['id'],
'variante_id' => $item['variante_id'] ?? null,
'bodega_id' => $bodegaIdFallback,
'cantidad' => $cantidadPendiente,
'precio_unitario' => $precioModificado,
'precio_original' => $precioOriginal,
'descuento_aplicado' => $descuentoAplicado,
'usuario_modifico_precio_id' => ($descuentoAplicado != 0) ? auth()->id() : null,
'subtotal' => $cantidadPendiente * $precioModificado,
]);
// Intentar descontar del fallback
if ($bodegaFallback) {
if ($variante) {
$bodegaVariante = $variante->bodegas()->where('bodega_id', $bodegaFallback->id)->first();
if ($bodegaVariante) {
$variante->bodegas()->updateExistingPivot($bodegaFallback->id, [
'stock' => $bodegaVariante->pivot->stock - $cantidadPendiente
]);
} else {
// Si no existe la relación, la creamos con stock negativo
$variante->bodegas()->attach($bodegaFallback->id, ['stock' => -$cantidadPendiente]);
}
} else {
$bodegaProducto = $producto->bodegas()->where('bodega_id', $bodegaFallback->id)->first();
if ($bodegaProducto) {
$producto->bodegas()->updateExistingPivot($bodegaFallback->id, [
'stock' => $bodegaProducto->pivot->stock - $cantidadPendiente
]);
} else {
$producto->bodegas()->attach($bodegaFallback->id, ['stock' => -$cantidadPendiente]);
}
}
} else {
// Si no hay bodegas en absoluto, descontar del stock directo
if ($variante) {
$variante->decrement('stock', $cantidadPendiente);
} else {
$producto->decrement('stock', $cantidadPendiente);
}
}
}
}
$cajaAbierta = Caja::where('estado', 'Abierta')
->orderBy('fecha_apertura', 'desc')
->first();
if ($cajaAbierta) {
MovimientoCaja::create([
'caja_id' => $cajaAbierta->id,
'tipo' => 'Ingreso',
'monto' => $this->total,
'descripcion' => 'Venta realizada. Venta ID: ' . $venta->id,
]);
}
DB::commit();
$this->resetVentaState();
session()->flash('message', 'Venta realizada con éxito.');
$this->js("window.dispatchEvent(new CustomEvent('imprimir-recibo', { detail: { venta_id: {$venta->id} } }));");
} catch (\Exception $e) {
DB::rollBack();
$this->resetVentaState();
session()->flash('error', 'Error al procesar la venta. ' . $e->getMessage());
}
}
/**
* Resetea el estado de la venta para evitar inconsistencias
*/
private function resetVentaState()
{
$this->carrito = [];
$this->total = 0;
$this->numeroDocumentoCliente = '';
$this->datosCliente = [
'nombre' => null,
'correo' => null,
'telefono' => null,
];
$this->cliente_id = null;
$this->tipo_pago = 'Efectivo';
// Mantener la bodega seleccionada para la próxima venta
// $this->bodega_id se mantiene
}
public function buscarProductoPorBarcode()
{
$barcode = trim($this->barcodeBusqueda);
$producto = Producto::where('codigo_barras', $barcode)->first();
$tipo = 'p';
if (!$producto) {
$producto = ProductVariant::where('barcode', $barcode)->first();
$tipo = 'v';
}
if ($producto) {
$this->agregarAlCarrito($producto->id, $tipo);
$this->barcodeBusqueda = '';
} else {
session()->flash('error', 'Producto no encontrado.');
}
}
public function incrementarCantidad($key)
{
if (isset($this->carrito[$key])) {
$this->carrito[$key]['cantidad']++;
$this->calcularTotal();
}
}
public function decrementarCantidad($key)
{
if (isset($this->carrito[$key])) {
$this->carrito[$key]['cantidad']--;
if ($this->carrito[$key]['cantidad'] <= 0) {
unset($this->carrito[$key]);
}
$this->calcularTotal();
}
}
public function eliminarDelCarrito($key)
{
if (isset($this->carrito[$key])) {
unset($this->carrito[$key]);
$this->calcularTotal();
}
}
public function enviarCotizacion()
{
$correo = $this->correoCotizacion;
if (!filter_var($correo, FILTER_VALIDATE_EMAIL)) {
session()->flash('error', 'Correo inválido.');
return;
}
try {
$carrito = $this->carrito;
$total = $this->total;
Mail::send('emails.cotizacion', compact('carrito', 'total'), function ($message) use ($correo) {
$message->to($correo)
->subject('Cotización de productos');
});
session()->flash('message', 'Cotización enviada exitosamente.');
$this->correoCotizacion = '';
} catch (\Exception $e) {
session()->flash('error', 'Error al enviar la cotización: ' . $e->getMessage());
}
}
public function buscarClientePorDocumento()
{
$documento = trim($this->numeroDocumentoCliente);
if (!$documento) {
// No se requiere documento
$this->cliente_id = null;
$this->datosCliente = [
'nombre' => null,
'correo' => null,
'telefono' => null,
];
return;
}
$cliente = Cliente::where('numero_documento', $documento)->first();
if ($cliente) {
// Verificar que el cliente encontrado existe realmente
$clienteValidado = Cliente::find($cliente->id);
if ($clienteValidado) {
$this->cliente_id = $clienteValidado->id;
$this->datosCliente = [
'nombre' => $clienteValidado->nombre,
'correo' => $clienteValidado->correo,
'telefono' => $clienteValidado->telefono,
];
} else {
// Cliente no válido, resetear
$this->cliente_id = null;
$this->datosCliente = [
'nombre' => null,
'correo' => null,
'telefono' => null,
];
}
} else {
$this->cliente_id = null;
$this->datosCliente = [
'nombre' => null,
'correo' => null,
'telefono' => null,
];
}
}
public function cambiarBodega($bodegaId)
{
$this->bodega_id = $bodegaId;
// Opcional: mostrar notificación de cambio
session()->flash('message', 'Bodega cambiada. Las ventas se realizarán desde la nueva bodega seleccionada.');
}
public function getBodegasProperty()
{
return \App\Models\Bodega::all();
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Filament\Pages;
use Filament\Actions\MountableAction;
use Filament\Pages\Page;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Form;
use App\Models\Setting;
use Filament\Actions;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\File;
class SettingPage extends Page implements HasForms
{
use InteractsWithForms;
public $logo;
public $description;
public $primary_color;
public $secondary_color;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static string $view = 'filament.pages.settings';
protected static ?string $title = 'Configuraciones del Sistema';
protected static ?string $navigationGroup = 'Administración'; //
protected static ?string $navigationLabel = 'Ajustes';
public ?array $data = [];
public function mount()
{
// Obtener la configuración existente o valores predeterminados
$setting = Setting::first();
$this->form->fill($setting?->toArray() ?? [
'logo' => null,
'description' => null,
'primary_color' => '#3498db',
'secondary_color' => '#2ecc71',
]);
}
protected function getFormSchema(): array
{
return [
FileUpload::make('logo')
->image()
->label('Logo')
->directory('settings/logos')
->required(),
Textarea::make('description')
->label('Descripción')
->maxLength(500)
->required(),
ColorPicker::make('primary_color')
->label('Color Primario')
->required(),
ColorPicker::make('secondary_color')
->label('Color Secundario')
->required(),
];
}
public function save()
{
$data = $this->form->getState();
Setting::updateOrCreate(['id' => 1], $data);
// Enviar notificación de éxito
Notification::make()
->title('Éxito')
->body('Configuraciones guardadas exitosamente.')
->success()
->send();
//$this->notify('success', 'Configuraciones guardadas exitosamente.');
}
protected function makeForm(): Form
{
return Form::make($this)
->schema($this->getFormSchema())
->statePath('data');
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('Cerrar todas las sesiones')
->color('danger')
->requiresConfirmation()
->action(function () {
if (config('session.driver') === 'file') {
// Eliminar todos los archivos de sesión
$files = File::files(storage_path('framework/sessions'));
foreach ($files as $file) {
File::delete($file);
}
} elseif (config('session.driver') === 'database') {
DB::table('sessions')->truncate();
}
// Limpia tu propia sesión para desconectarte también
Session::flush();
Notification::make()
->title('Sesiones cerradas')
->body('Todas las sesiones han sido cerradas exitosamente.')
->success()
->send();
}),
];
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\BodegaResource\Pages;
use App\Filament\Resources\BodegaResource\RelationManagers;
use App\Models\Bodega;
use Filament\Forms\Form;
use Filament\Tables\Table;
use Filament\Resources\Resource;
use Filament\Forms\Components\TextInput;
use Filament\Tables;
use Filament\Notifications\Notification;
class BodegaResource extends Resource
{
protected static ?string $model = Bodega::class;
protected static ?string $navigationIcon = 'heroicon-o-cube';
protected static ?string $navigationGroup = 'Operación';
protected static ?string $navigationLabel = 'Bodegas';
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('nombre')
->required()
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('nombre')->searchable(),
Tables\Columns\TextColumn::make('stock_total')
->label('Stock total')
->getStateUsing(fn (Bodega $record) => $record->stock_total),
])
->filters([
// Puedes añadir filtros aquí si lo deseas
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->before(function (Bodega $record, Tables\Actions\DeleteAction $action) {
$hasStockProductos = $record->productos()->wherePivot('stock', '>', 0)->exists();
$hasStockVariantes = $record->variantes()->wherePivot('stock', '>', 0)->exists();
if ($hasStockProductos || $hasStockVariantes) {
Notification::make()
->warning()
->title('No se puede eliminar')
->body('Esta bodega tiene productos con stock asignado. Debe vaciar el stock antes de eliminarla.')
->persistent()
->send();
$action->cancel();
}
}),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make()
->action(function (Tables\Actions\DeleteBulkAction $action, \Illuminate\Database\Eloquent\Collection $records) {
foreach ($records as $record) {
$hasStockProductos = $record->productos()->wherePivot('stock', '>', 0)->exists();
$hasStockVariantes = $record->variantes()->wherePivot('stock', '>', 0)->exists();
if ($hasStockProductos || $hasStockVariantes) {
Notification::make()
->warning()
->title('No se puede eliminar')
->body("La bodega '{$record->nombre}' tiene productos con stock asignado. No se eliminó ninguna bodega.")
->persistent()
->send();
$action->cancel();
return;
}
}
$records->each->delete();
Notification::make()
->success()
->title('Bodegas eliminadas')
->body('Las bodegas seleccionadas han sido eliminadas.')
->send();
}),
]);
}
public static function getRelations(): array
{
return [
RelationManagers\ProductosRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListBodegas::route('/'),
'create' => Pages\CreateBodega::route('/create'),
'edit' => Pages\EditBodega::route('/{record}/edit'),
];
}
}
?>
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\BodegaResource\Pages;
use App\Filament\Resources\BodegaResource;
use Filament\Resources\Pages\CreateRecord;
class CreateBodega extends CreateRecord
{
protected static string $resource = BodegaResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\BodegaResource\Pages;
use App\Filament\Resources\BodegaResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditBodega extends EditRecord
{
protected static string $resource = BodegaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BodegaResource\Pages;
use App\Filament\Resources\BodegaResource;
use Filament\Resources\Pages\ListRecords;
use Filament\Actions;
class ListBodegas extends ListRecords
{
protected static string $resource = BodegaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
?>
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BodegaResource\Pages;
use App\Filament\Resources\BodegaResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewBodega extends ViewRecord
{
protected static string $resource = BodegaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Resources\BodegaResource\RelationManagers;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Forms;
class ProductosRelationManager extends RelationManager
{
protected static string $relationship = 'productos'; // nombre de la relación en el modelo Bodega
protected static ?string $recordTitleAttribute = 'nombre';
public function form(Forms\Form $form): Forms\Form
{
// No se necesita crear/editar productos aquí (opcional)
return $form;
}
public function table(Tables\Table $table): Tables\Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID')
->sortable(),
Tables\Columns\TextColumn::make('nombre')
->searchable()
->limit(50),
Tables\Columns\TextColumn::make('precio_venta')
->label('Precio venta')
->money('USD', true),
// Stock proviene del pivot (producto_bodega.stock)
Tables\Columns\TextColumn::make('pivot.stock')
->label('Stock')
->sortable(),
])
->filters([
// Puedes añadir filtros aquí si lo deseas
])
->headerActions([
// Si deseas crear productos desde aquí, descomenta:
// Tables\Actions\CreateAction::make(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make(),
]);
}
}
?>
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\CajaResource\Pages;
use App\Filament\Resources\CajaResource\RelationManagers;
use App\Models\Caja;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use App\Filament\Resources\CajasResource\RelationManagers\DetallesRelationManagerRelationManager;
use Filament\Forms\Components\Select;
use App\Models\MovimientoCaja;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\TextColumn;
class CajaResource extends Resource
{
protected static ?string $model = Caja::class;
protected static ?string $navigationIcon = 'heroicon-o-receipt-refund';
protected static ?string $navigationGroup = 'Operación';
public static function canViewAny(): bool
{
return auth()->user()->can('ver caja');
}
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('monto_inicial')
->required()
->numeric(),
TextInput::make('monto_final')
->disabled(), // Deshabilitar para cálculo automático
Forms\Components\DateTimePicker::make('fecha_apertura')
->required(),
Forms\Components\DateTimePicker::make('fecha_cierre'),
Select::make('estado')
->label('Estado')
->required()
->options([
'Abierta' => 'Abierta',
'Cerrada' => 'Cerrada',
])
->afterStateUpdated(function (callable $set, $state, $get) {
// Si el estado se cambia a 'Cerrada', actualizamos la fecha de cierre y calculamos el monto final
if ($state === 'Cerrada') {
// Actualizamos la fecha de cierre al momento actual
$set('fecha_cierre', now());
// Obtener el ID del registro actual desde el contexto
$cajaId = $get('id'); // Usamos $get para obtener el ID del registro
// Buscar la caja por su ID
$caja = Caja::find($cajaId); // Encontrar la caja por ID
if ($caja) {
// Calcular monto final de acuerdo a los movimientos de caja
$montoFinal = $caja->calcularMontoFinal();
// Guardar el monto final calculado
$caja->update([
'monto_final' => $montoFinal, // Actualizamos el monto final en la base de datos
]);
// Ahora, actualizamos el estado del formulario
$set('monto_final', $montoFinal); // Actualizar el estado del formulario
}
}
}),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('monto_inicial')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('monto_final')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('fecha_apertura')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('fecha_cierre')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('estado'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
// Filtros adicionales, si los necesitas
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
DetallesRelationManagerRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListCajas::route('/'),
'create' => Pages\CreateCaja::route('/create'),
'edit' => Pages\EditCaja::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\CajaResource\Pages;
use App\Filament\Resources\CajaResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateCaja extends CreateRecord
{
protected static string $resource = CajaResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CajaResource\Pages;
use App\Filament\Resources\CajaResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCaja extends EditRecord
{
protected static string $resource = CajaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CajaResource\Pages;
use App\Filament\Resources\CajaResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCajas extends ListRecords
{
protected static string $resource = CajaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Filament\Resources\CajasResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class DetallesRelationManagerRelationManager extends RelationManager
{
protected static string $relationship = 'movimientoscaja';
public function form(Form $form): Form
{
return $form->schema([
Forms\Components\Select::make('tipo')
->required()
->options([
'Ingreso' => 'Ingreso',
'Egreso' => 'Egreso',
])
->placeholder('Selecciona un tipo'),
Forms\Components\TextInput::make('monto')
->numeric()
->required(),
Forms\Components\TextInput::make('descripcion')
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table->columns([
Tables\Columns\TextColumn::make('tipo')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('monto')
->sortable()
->numeric(),
Tables\Columns\TextColumn::make('descripcion'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->filters([
// Aquí puedes agregar filtros si es necesario.
])
->headerActions([
Tables\Actions\CreateAction::make(),
]);
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\CategoriaResource\Pages;
use App\Filament\Resources\CategoriaResource\RelationManagers;
use App\Models\Categoria;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class CategoriaResource extends Resource
{
protected static ?string $model = Categoria::class;
protected static ?string $navigationGroup = 'Inventario'; //
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
public static function canViewAny(): bool
{
return auth()->user()->can('ver categoria');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('nombre')
->required()
->maxLength(255),
Forms\Components\Textarea::make('descripcion')
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('nombre')
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListCategorias::route('/'),
'create' => Pages\CreateCategoria::route('/create'),
'edit' => Pages\EditCategoria::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\CategoriaResource\Pages;
use App\Filament\Resources\CategoriaResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateCategoria extends CreateRecord
{
protected static string $resource = CategoriaResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CategoriaResource\Pages;
use App\Filament\Resources\CategoriaResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCategoria extends EditRecord
{
protected static string $resource = CategoriaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CategoriaResource\Pages;
use App\Filament\Resources\CategoriaResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCategorias extends ListRecords
{
protected static string $resource = CategoriaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ClienteResource\Pages;
use App\Filament\Resources\ClienteResource\RelationManagers;
use App\Models\Cliente;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class ClienteResource extends Resource
{
protected static ?string $model = Cliente::class;
protected static ?string $navigationGroup = 'Operación'; //
protected static ?string $navigationIcon = 'heroicon-o-user';
public static function canViewAny(): bool
{
return auth()->user()->can('ver clientes');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('nombre')
->required()
->maxLength(255),
Forms\Components\TextInput::make('correo')
->maxLength(255),
Forms\Components\TextInput::make('telefono')
->tel()
->maxLength(20),
Forms\Components\TextInput::make('numero_documento')
->required()
->maxLength(20),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('nombre')
->searchable(),
Tables\Columns\TextColumn::make('correo')
->searchable(),
Tables\Columns\TextColumn::make('telefono')
->searchable(),
Tables\Columns\TextColumn::make('numero_documento')
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListClientes::route('/'),
'create' => Pages\CreateCliente::route('/create'),
'edit' => Pages\EditCliente::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\ClienteResource\Pages;
use App\Filament\Resources\ClienteResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateCliente extends CreateRecord
{
protected static string $resource = ClienteResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ClienteResource\Pages;
use App\Filament\Resources\ClienteResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCliente extends EditRecord
{
protected static string $resource = ClienteResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ClienteResource\Pages;
use App\Filament\Resources\ClienteResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListClientes extends ListRecords
{
protected static string $resource = ClienteResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ColorResource\Pages;
use App\Filament\Resources\ColorResource\RelationManagers;
use App\Models\Color;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Filament\Tables\Columns\ColorColumn;
use Filament\Forms\Components\ColorPicker;
class ColorResource extends Resource
{
protected static ?string $navigationGroup = 'Inventario'; //
protected static ?string $model = Color::class;
protected static ?string $navigationIcon = 'heroicon-o-swatch';
public static function getModelLabel(): string
{
return 'Color'; // Nombre singular
}
public static function getPluralModelLabel(): string
{
return 'Colores'; // Nombre plural
}
public static function getNavigationLabel(): string
{
return 'Colores'; // Nombre en el menú de navegación
}
public static function canViewAny(): bool
{
return auth()->user()->can('ver colores');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(50),
ColorPicker::make('hex_code')
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
ColorColumn::make('hex_code')
->copyable() // Permite copiar el código HEX
->sortable(), // Permite ordenar por color
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListColors::route('/'),
'create' => Pages\CreateColor::route('/create'),
'edit' => Pages\EditColor::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\ColorResource\Pages;
use App\Filament\Resources\ColorResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateColor extends CreateRecord
{
protected static string $resource = ColorResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ColorResource\Pages;
use App\Filament\Resources\ColorResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditColor extends EditRecord
{
protected static string $resource = ColorResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ColorResource\Pages;
use App\Filament\Resources\ColorResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListColors extends ListRecords
{
protected static string $resource = ColorResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
+344
View File
@@ -0,0 +1,344 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\CompraResource\Pages;
use App\Filament\Resources\CompraResource\RelationManagers;
use App\Filament\Resources\CompraResource\RelationManagers\DetallesRelationManagerRelationManager;
use App\Models\Compra;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Filament\Forms\Get;
use Filament\Forms\Set;
use App\Models\Producto;
use App\Models\ProductVariant;
use App\Models\Bodega;
use Filament\Notifications\Notification;
class CompraResource extends Resource
{
protected static ?string $model = Compra::class;
protected static ?string $navigationGroup = 'Operación'; //
protected static ?string $navigationIcon = 'heroicon-o-credit-card';
public static function canViewAny(): bool
{
return auth()->user()->can('ver compras');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Información de la Compra')
->schema([
Forms\Components\Select::make('proveedor_id')
->relationship('proveedor', 'nombre')
->required()
->columnSpan(1)
->helperText('Selecciona el proveedor de esta compra'),
Forms\Components\Select::make('estado')
->required()
->columnSpan(1)
->options(function (Get $get) {
// Solo permite 'Pendiente' si no hay ID (creación)
return request()->routeIs('filament.admin.resources.compras.create')
? ['Pendiente' => 'Pendiente']
: [
'Pendiente' => 'Pendiente',
'Recibida' => 'Recibida',
'Anulada' => 'Anulada',
];
})
->default('Pendiente')
->disabled(fn () => request()->routeIs('filament.admin.resources.compras.create'))
->helperText(function (Get $get) {
return request()->routeIs('filament.admin.resources.compras.create')
? 'Las compras se crean en estado "Pendiente"'
: 'Cambiar a "Recibida" actualizará automáticamente el stock en las bodegas';
}),
Forms\Components\Placeholder::make('total_display')
->label('Total de la Compra')
->columnSpan(2)
->content(function (Get $get) {
$detalles = $get('detalles') ?? [];
$total = collect($detalles)->sum('subtotal');
return '$' . number_format($total, 2);
}),
])
->columns(2),
Forms\Components\Section::make('Detalles de la Compra')
->description('Agrega los productos de esta compra de forma rápida')
->schema([
Forms\Components\Repeater::make('detalles')
->relationship('detalles')
->schema([
Forms\Components\TextInput::make('codigo_escaneado')
->label('Código de barras')
->placeholder('Escanea o escribe...')
->live(onBlur: true)
->columnSpan(2)
->afterStateUpdated(function (Set $set, $state) {
if (!$state) return;
// Buscar en ProductVariant
$variant = ProductVariant::where('barcode', $state)->first();
if ($variant) {
$set('producto_id', $variant->producto_id);
$set('variante_id', $variant->id);
$colorName = $variant->color ? $variant->color->name : 'Sin color';
$sizeName = $variant->size ? $variant->size->name : 'Sin talla';
Notification::make()
->success()
->title('✓ Variante encontrada')
->body("{$variant->producto->nombre} - {$colorName}/{$sizeName}")
->send();
return;
}
// Buscar en Producto
$producto = Producto::where('codigo_barras', $state)->first();
if ($producto) {
$set('producto_id', $producto->id);
$set('variante_id', null);
if ($producto->variants()->exists()) {
Notification::make()
->warning()
->title('⚠ Producto con variantes')
->body('Selecciona una variante específica.')
->send();
} else {
Notification::make()
->success()
->title('✓ Producto encontrado')
->body($producto->nombre)
->send();
}
} else {
Notification::make()
->warning()
->title('Código no encontrado')
->body('No existe un producto con este código.')
->send();
}
}),
Forms\Components\Select::make('bodega_id')
->label('Bodega')
->relationship('bodega', 'nombre')
->searchable()
->required()
->columnSpan(1)
->default(function () {
$bodegaPrincipal = Bodega::where('nombre', 'Principal')->first();
return $bodegaPrincipal ? $bodegaPrincipal->id : null;
}),
Forms\Components\Select::make('producto_id')
->label('Producto')
->relationship('producto', 'nombre')
->searchable()
->required()
->columnSpan(2)
->live()
->afterStateUpdated(function (Set $set, $state) {
$set('variante_id', null);
if ($state) {
$producto = Producto::find($state);
if ($producto && $producto->variants()->exists()) {
Notification::make()
->info()
->title('Producto con variantes')
->body('Selecciona una variante.')
->send();
}
}
}),
Forms\Components\Select::make('variante_id')
->label('Variante')
->columnSpan(2)
->options(function (Get $get) {
$productoId = $get('producto_id');
if (!$productoId) return [];
$producto = Producto::find($productoId);
if (!$producto || !$producto->variants()->exists()) return [];
return ProductVariant::where('producto_id', $productoId)
->with(['color', 'size'])
->get()
->mapWithKeys(function ($variant) {
$colorName = $variant->color ? $variant->color->name : 'Sin color';
$sizeName = $variant->size ? $variant->size->name : 'Sin talla';
return [$variant->id => "{$colorName} / {$sizeName}"];
});
})
->searchable()
->live()
->visible(function (Get $get) {
$productoId = $get('producto_id');
if (!$productoId) return false;
$producto = Producto::find($productoId);
return $producto && $producto->variants()->exists();
})
->required(function (Get $get) {
$productoId = $get('producto_id');
if (!$productoId) return false;
$producto = Producto::find($productoId);
return $producto && $producto->variants()->exists();
}),
Forms\Components\TextInput::make('cantidad')
->label('Cant.')
->required()
->numeric()
->default(1)
->columnSpan(1)
->live(onBlur: true)
->afterStateUpdated(function (Set $set, Get $get) {
$cantidad = (float) ($get('cantidad') ?? 0);
$precio = (float) ($get('precio_unitario') ?? 0);
$set('subtotal', $cantidad * $precio);
}),
Forms\Components\TextInput::make('precio_unitario')
->label('Precio Unit.')
->required()
->numeric()
->prefix('$')
->columnSpan(1)
->live(onBlur: true)
->afterStateUpdated(function (Set $set, Get $get) {
$cantidad = (float) ($get('cantidad') ?? 0);
$precio = (float) ($get('precio_unitario') ?? 0);
$set('subtotal', $cantidad * $precio);
}),
Forms\Components\TextInput::make('subtotal')
->label('Subtotal')
->numeric()
->prefix('$')
->disabled()
->dehydrated(true)
->columnSpan(1),
Forms\Components\TextInput::make('DetalleCompra')
->label('Observaciones')
->maxLength(255)
->columnSpan(3),
])
->columns(6)
->defaultItems(1)
->reorderable(false)
->collapsible()
->itemLabel(fn (array $state): ?string =>
isset($state['producto_id'])
? Producto::find($state['producto_id'])?->nombre ?? 'Producto'
: 'Nuevo producto'
)
->addActionLabel('+ Agregar producto')
->live()
->afterStateUpdated(function (Set $set, Get $get) {
// Calcular el total cuando cambian los detalles
$detalles = $get('detalles') ?? [];
$total = collect($detalles)->sum('subtotal');
$set('total', $total);
}),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID')
->sortable(),
Tables\Columns\TextColumn::make('proveedor.nombre')
->label('Proveedor')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('total')
->label('Total')
->money('cop')
->sortable()
->alignEnd(),
Tables\Columns\BadgeColumn::make('estado')
->label('Estado')
->colors([
'warning' => 'Pendiente',
'success' => 'Recibida',
'danger' => 'Anulada',
]),
Tables\Columns\TextColumn::make('detalles_count')
->label('Items')
->counts('detalles')
->badge()
->color('primary'),
Tables\Columns\TextColumn::make('created_at')
->label('Fecha')
->dateTime('d/m/Y H:i')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->label('Actualizada')
->dateTime('d/m/Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
DetallesRelationManagerRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListCompras::route('/'),
'create' => Pages\CreateCompra::route('/create'),
'edit' => Pages\EditCompra::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Filament\Resources\CompraResource\Pages;
use App\Filament\Resources\CompraResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
use App\Models\Producto;
use App\Models\ProductVariant;
use App\Imports\CompraDetallesImport;
use App\Exports\PlantillaCompraExport;
use Maatwebsite\Excel\Facades\Excel;
use Filament\Forms\Components\FileUpload;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
class CreateCompra extends CreateRecord
{
protected static string $resource = CompraResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('descargar_plantilla')
->label('Descargar Plantilla Excel')
->icon('heroicon-o-document-arrow-down')
->color('info')
->action(function () {
return Excel::download(
new PlantillaCompraExport(),
'plantilla_compras.xlsx'
);
}),
Actions\Action::make('importar_excel')
->label('Importar desde Excel')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->form([
FileUpload::make('archivo')
->label('Archivo Excel')
->acceptedFileTypes([
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
])
->required()
->helperText('Formato: Código de Barras, Producto, Cantidad, Precio Unitario, Bodega (opcional), Observaciones (opcional)')
->disk('local')
->directory('temp-imports'),
])
->action(function (array $data) {
try {
$filePath = Storage::disk('local')->path($data['archivo']);
$import = new CompraDetallesImport();
Excel::import($import, $filePath);
$previewData = $import->getPreviewData();
$stats = $import->getStats();
// Mostrar vista preliminar en notificación
$message = "Total de filas: {$stats['total']}\n";
$message .= "Válidas: {$stats['valid']}\n";
$message .= "Con errores: {$stats['invalid']}\n";
if ($stats['productos_creados'] > 0) {
$message .= "✨ Productos nuevos creados: {$stats['productos_creados']}\n";
}
$message .= "Total estimado: $" . number_format($stats['total_amount'], 2);
// Si hay errores, mostrarlos
if ($stats['invalid'] > 0) {
$errorMessages = collect($previewData)
->filter(fn($item) => !$item['valid'])
->map(fn($item) => "Fila {$item['row_number']}: " . implode(', ', $item['errors']))
->take(5)
->implode("\n");
Notification::make()
->warning()
->title("Vista preliminar: {$stats['invalid']} filas con errores")
->body($errorMessages . "\n\n" . $message)
->persistent()
->send();
} else {
Notification::make()
->success()
->title('Vista preliminar de importación')
->body($message)
->persistent()
->send();
}
// Cargar los datos directamente en el formulario
$detallesParaFormulario = $import->getDetallesForSave();
// Actualizar el formulario con los datos importados
$currentData = $this->data;
$currentData['detalles'] = array_merge(
$currentData['detalles'] ?? [],
$detallesParaFormulario
);
// Calcular el total
$currentData['total'] = collect($currentData['detalles'])->sum('subtotal');
$this->form->fill($currentData);
// Limpiar archivo temporal
Storage::disk('local')->delete($data['archivo']);
} catch (\Exception $e) {
Notification::make()
->danger()
->title('Error en la importación')
->body($e->getMessage())
->send();
}
}),
];
}
protected function mutateFormDataBeforeCreate(array $data): array
{
// Calcular el total basado en los detalles
if (isset($data['detalles']) && is_array($data['detalles'])) {
$total = collect($data['detalles'])->sum('subtotal');
$data['total'] = $total;
// Agregar snapshots para cada detalle
foreach ($data['detalles'] as &$detalle) {
if (isset($detalle['producto_id'])) {
$producto = Producto::find($detalle['producto_id']);
$detalle['producto_nombre_snapshot'] = $producto?->nombre;
}
if (isset($detalle['variante_id'])) {
$variante = ProductVariant::find($detalle['variante_id']);
if ($variante) {
$colorName = $variante->color?->name ?? 'Sin color';
$sizeName = $variante->size?->name ?? 'Sin talla';
$detalle['variante_info_snapshot'] = "$colorName / $sizeName";
}
}
}
} else {
$data['total'] = 0;
}
return $data;
}
}
@@ -0,0 +1,186 @@
<?php
namespace App\Filament\Resources\CompraResource\Pages;
use App\Filament\Resources\CompraResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use App\Models\Producto;
use App\Models\ProductVariant;
use App\Imports\CompraDetallesImport;
use App\Exports\PlantillaCompraExport;
use Maatwebsite\Excel\Facades\Excel;
use Filament\Forms\Components\FileUpload;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
class EditCompra extends EditRecord
{
protected static string $resource = CompraResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('marcar_recibida')
->label('Marcar como Recibida')
->icon('heroicon-o-check-circle')
->color('success')
->visible(fn() => $this->record->estado === 'Pendiente')
->requiresConfirmation()
->modalHeading('Marcar compra como recibida')
->modalDescription('Esto actualizará el stock en las bodegas según los productos de esta compra.')
->action(function () {
$this->record->update(['estado' => 'Recibida']);
Notification::make()
->success()
->title('Compra recibida')
->body('El stock ha sido actualizado en las bodegas.')
->send();
$this->redirect(static::getResource()::getUrl('edit', ['record' => $this->record]));
}),
Actions\Action::make('descargar_plantilla')
->label('Descargar Plantilla Excel')
->icon('heroicon-o-document-arrow-down')
->color('info')
->action(function () {
return Excel::download(
new PlantillaCompraExport(),
'plantilla_compras.xlsx'
);
}),
Actions\Action::make('importar_excel')
->label('Importar desde Excel')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->form([
FileUpload::make('archivo')
->label('Archivo Excel')
->acceptedFileTypes([
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
])
->required()
->helperText('Formato: Código de Barras, Producto, Cantidad, Precio Unitario, Bodega (opcional), Observaciones (opcional)')
->disk('local')
->directory('temp-imports'),
])
->action(function (array $data) {
try {
$filePath = Storage::disk('local')->path($data['archivo']);
$import = new CompraDetallesImport();
Excel::import($import, $filePath);
$previewData = $import->getPreviewData();
$stats = $import->getStats();
// Si hay errores, mostrarlos
if ($stats['invalid'] > 0) {
$errorMessages = collect($previewData)
->filter(fn($item) => !$item['valid'])
->map(fn($item) => "Fila {$item['row_number']}: " . implode(', ', $item['errors']))
->take(10)
->implode("\n");
Notification::make()
->warning()
->title("Se encontraron {$stats['invalid']} filas con errores")
->body($errorMessages)
->persistent()
->send();
}
// Obtener detalles válidos para agregar
$detallesValidos = $import->getDetallesForSave();
if (count($detallesValidos) > 0) {
// Agregar los detalles a la compra
foreach ($detallesValidos as $detalle) {
$this->record->detalles()->create($detalle);
}
// Recalcular el total
$nuevoTotal = $this->record->detalles()->sum('subtotal');
$this->record->update(['total' => $nuevoTotal]);
$mensaje = "{$stats['valid']} productos agregados. Total: $" . number_format($stats['total_amount'], 2);
if ($stats['productos_creados'] > 0) {
$mensaje .= "\n{$stats['productos_creados']} productos nuevos creados automáticamente";
}
Notification::make()
->success()
->title('Importación exitosa')
->body($mensaje)
->send();
// Si la compra está en estado Pendiente, preguntar si desea recibirla
if ($this->record->estado === 'Pendiente') {
Notification::make()
->info()
->title('Actualizar stock')
->body('⚠️ La compra está en estado "Pendiente". Cambia el estado a "Recibida" para actualizar el stock en las bodegas.')
->persistent()
->send();
}
// Refrescar la página
$this->redirect(static::getResource()::getUrl('edit', ['record' => $this->record]));
} else {
Notification::make()
->warning()
->title('No hay datos válidos para importar')
->send();
}
// Limpiar archivo temporal
Storage::disk('local')->delete($data['archivo']);
} catch (\Exception $e) {
Notification::make()
->danger()
->title('Error en la importación')
->body($e->getMessage())
->send();
}
}),
Actions\DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
// Calcular el total basado en los detalles
if (isset($data['detalles']) && is_array($data['detalles'])) {
$total = collect($data['detalles'])->sum('subtotal');
$data['total'] = $total;
// Agregar snapshots para cada detalle
foreach ($data['detalles'] as &$detalle) {
if (isset($detalle['producto_id'])) {
$producto = Producto::find($detalle['producto_id']);
$detalle['producto_nombre_snapshot'] = $producto?->nombre;
}
if (isset($detalle['variante_id'])) {
$variante = ProductVariant::find($detalle['variante_id']);
if ($variante) {
$colorName = $variante->color?->name ?? 'Sin color';
$sizeName = $variante->size?->name ?? 'Sin talla';
$detalle['variante_info_snapshot'] = "$colorName / $sizeName";
}
}
}
} else {
$data['total'] = 0;
}
return $data;
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Filament\Resources\CompraResource\Pages;
use App\Filament\Resources\CompraResource;
use App\Exports\PlantillaCompraExport;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Maatwebsite\Excel\Facades\Excel;
class ListCompras extends ListRecords
{
protected static string $resource = CompraResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('descargar_plantilla')
->label('Descargar Plantilla Excel')
->icon('heroicon-o-document-arrow-down')
->color('info')
->action(function () {
return Excel::download(
new PlantillaCompraExport(),
'plantilla_compras.xlsx'
);
}),
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,275 @@
<?php
namespace App\Filament\Resources\CompraResource\RelationManagers;
use App\Models\Producto;
use App\Models\ProductVariant;
use App\Models\Bodega;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\DB;
class DetallesRelationManagerRelationManager extends RelationManager
{
protected static string $relationship = 'detalles';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('codigo_escaneado')
->label('Escanear código de barras')
->live()
->afterStateUpdated(function (callable $set, $state) {
// Buscar en ProductVariant
$variant = ProductVariant::where('barcode', $state)->first();
if ($variant) {
$set('producto_id', $variant->producto_id);
$set('variante_id', $variant->id);
// Validación defensiva para evitar error "name" on null
$colorName = $variant->color ? $variant->color->name : 'Sin color';
$sizeName = $variant->size ? $variant->size->name : 'Sin talla';
Notification::make()
->success()
->title('Variante encontrada')
->body("Producto: {$variant->producto->nombre} - Variante: {$colorName}/{$sizeName}")
->send();
return;
}
// Buscar en Producto
$producto = Producto::where('codigo_barras', $state)->first();
if ($producto) {
$set('producto_id', $producto->id);
$set('variante_id', null);
if ($producto->variants()->exists()) {
Notification::make()
->warning()
->title('Producto con variantes')
->body('Este producto tiene variantes. Por favor selecciona una variante específica.')
->send();
} else {
Notification::make()
->success()
->title('Producto encontrado')
->body("Producto: {$producto->nombre}")
->send();
}
} else {
Notification::make()
->warning()
->title('Código no encontrado')
->body('No se encontró ningún producto o variante con este código.')
->send();
}
}),
Select::make('bodega_id')
->label('Bodega de Destino')
->relationship('bodega', 'nombre')
->searchable()
->required()
->default(function () {
// Buscar bodega "Principal" como default
$bodegaPrincipal = Bodega::where('nombre', 'Principal')->first();
return $bodegaPrincipal ? $bodegaPrincipal->id : null;
})
->helperText('Selecciona la bodega donde se almacenará este producto'),
Select::make('producto_id')
->label('Producto')
->relationship('producto', 'nombre')
->searchable()
->required()
->live()
->afterStateUpdated(function (callable $set, $state) {
// Limpiar variante cuando cambia el producto
$set('variante_id', null);
if ($state) {
$producto = Producto::find($state);
if ($producto && $producto->variants()->exists()) {
Notification::make()
->info()
->title('Producto con variantes')
->body('Este producto tiene variantes disponibles. Por favor selecciona una.')
->send();
}
}
}),
Select::make('variante_id')
->label('Variante')
->options(function (callable $get) {
$productoId = $get('producto_id');
if (!$productoId) {
return [];
}
$producto = Producto::find($productoId);
if (!$producto || !$producto->variants()->exists()) {
return [];
}
return ProductVariant::where('producto_id', $productoId)
->with(['color', 'size'])
->get()
->mapWithKeys(function ($variant) {
// Validación defensiva para evitar error "name" on null
$colorName = $variant->color ? $variant->color->name : 'Sin color';
$sizeName = $variant->size ? $variant->size->name : 'Sin talla';
return [
$variant->id => "{$colorName} / {$sizeName}",
];
});
})
->searchable()
->live()
->visible(function (callable $get) {
$productoId = $get('producto_id');
if (!$productoId) return false;
$producto = Producto::find($productoId);
return $producto && $producto->variants()->exists();
})
->required(function (callable $get) {
$productoId = $get('producto_id');
if (!$productoId) return false;
$producto = Producto::find($productoId);
return $producto && $producto->variants()->exists();
})
->helperText(function (callable $get) {
$productoId = $get('producto_id');
if (!$productoId) return null;
$producto = Producto::find($productoId);
if (!$producto || !$producto->variants()->exists()) {
return null;
}
return 'Este producto requiere seleccionar una variante específica.';
}),
TextInput::make('cantidad')
->required()
->numeric()
->live()
->helperText('Cantidad en unidades individuales')
->afterStateUpdated(function (callable $set, callable $get) {
$cantidad = (float) $get('cantidad');
$precio = (float) $get('precio_unitario');
$set('subtotal', $cantidad * $precio);
}),
TextInput::make('precio_unitario')
->required()
->numeric()
->prefix('$')
->live()
->helperText('Precio por unidad individual')
->afterStateUpdated(function (callable $set, callable $get) {
$cantidad = (float) $get('cantidad');
$precio = (float) $get('precio_unitario');
$set('subtotal', $cantidad * $precio);
}),
TextInput::make('subtotal')
->numeric()
->prefix('$')
->disabled()
->dehydrated(true), // guarda el valor aunque esté deshabilitado
TextInput::make('DetalleCompra')
->label('Detalle/Observaciones')
->maxLength(255)
->helperText('Información adicional sobre esta compra (opcional)'),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('DetalleCompra')
->columns([
Tables\Columns\TextColumn::make('bodega.nombre')
->label('Bodega')
->badge()
->color('primary'),
Tables\Columns\TextColumn::make('producto_nombre')
->label('Producto')
->searchable()
->sortable()
->color(fn($record) => $record->producto_id ? 'primary' : 'warning')
->icon(fn($record) => $record->producto_id ? null : 'heroicon-o-archive-box-x-mark')
->tooltip(fn($record) => $record->producto_id ? null : 'Producto eliminado - información histórica'),
Tables\Columns\TextColumn::make('variante_info')
->label('Variante')
->badge()
->color(function ($record) {
if ($record->variante_id) return 'success';
if ($record->variante_info_snapshot) return 'warning';
return 'gray';
})
->formatStateUsing(fn($record) => $record->variante_info ?: '—')
->tooltip(function ($record) {
if ($record->variante_id) return null;
if ($record->variante_info_snapshot) return 'Variante eliminada - información histórica';
return 'Sin variante';
}),
Tables\Columns\TextColumn::make('cantidad')
->label('Cantidad')
->alignEnd(),
Tables\Columns\TextColumn::make('precio_unitario')
->label('Precio Unitario')
->money('cop')
->alignEnd(),
Tables\Columns\TextColumn::make('subtotal')
->label('Subtotal')
->money('cop')
->alignEnd()
->weight('bold')
->color('primary'),
Tables\Columns\TextColumn::make('DetalleCompra')
->label('Detalle')
->limit(30)
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->headerActions([
Tables\Actions\CreateAction::make(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,109 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\DetalleCompraResource\Pages;
use App\Filament\Resources\DetalleCompraResource\RelationManagers;
use App\Models\DetalleCompra;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class DetalleCompraResource extends Resource
{
protected static ?string $model = DetalleCompra::class;
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';
public static function canViewAny(): bool
{
return auth()->user()->can('ver detalle de compras');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Select::make('compra_id')
->relationship('compra', 'id')
->required(),
Forms\Components\Select::make('producto_id')
->relationship('producto', 'id')
->required(),
Forms\Components\TextInput::make('cantidad')
->required()
->numeric(),
Forms\Components\TextInput::make('precio_unitario')
->required()
->numeric(),
Forms\Components\TextInput::make('subtotal')
->required()
->numeric(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('compra.id')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('producto.id')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('cantidad')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('precio_unitario')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('subtotal')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListDetalleCompras::route('/'),
'create' => Pages\CreateDetalleCompra::route('/create'),
'edit' => Pages\EditDetalleCompra::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\DetalleCompraResource\Pages;
use App\Filament\Resources\DetalleCompraResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateDetalleCompra extends CreateRecord
{
protected static string $resource = DetalleCompraResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DetalleCompraResource\Pages;
use App\Filament\Resources\DetalleCompraResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditDetalleCompra extends EditRecord
{
protected static string $resource = DetalleCompraResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DetalleCompraResource\Pages;
use App\Filament\Resources\DetalleCompraResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListDetalleCompras extends ListRecords
{
protected static string $resource = DetalleCompraResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\DetalleVentaResource\Pages;
use App\Filament\Resources\DetalleVentaResource\RelationManagers;
use App\Models\DetalleVenta;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class DetalleVentaResource extends Resource
{
protected static ?string $model = DetalleVenta::class;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
public static function canViewAny(): bool
{
return auth()->user()->can('ver detalle de ventas');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Select::make('venta_id')
->relationship('venta', 'id')
->required(),
Forms\Components\Select::make('producto_id')
->relationship('producto', 'id')
->required(),
Forms\Components\TextInput::make('cantidad')
->required()
->numeric(),
Forms\Components\TextInput::make('precio_unitario')
->required()
->numeric(),
Forms\Components\TextInput::make('subtotal')
->required()
->numeric(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('venta.id')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('producto.id')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('cantidad')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('precio_unitario')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('subtotal')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListDetalleVentas::route('/'),
'create' => Pages\CreateDetalleVenta::route('/create'),
'edit' => Pages\EditDetalleVenta::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\DetalleVentaResource\Pages;
use App\Filament\Resources\DetalleVentaResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateDetalleVenta extends CreateRecord
{
protected static string $resource = DetalleVentaResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DetalleVentaResource\Pages;
use App\Filament\Resources\DetalleVentaResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditDetalleVenta extends EditRecord
{
protected static string $resource = DetalleVentaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DetalleVentaResource\Pages;
use App\Filament\Resources\DetalleVentaResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListDetalleVentas extends ListRecords
{
protected static string $resource = DetalleVentaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\InformeResource\Pages;
use App\Filament\Resources\InformeResource\Widgets;
use App\Models\Venta;
use App\Models\DetalleVenta;
use App\Models\Producto;
use App\Models\Categoria;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class InformeResource extends Resource
{
protected static ?string $model = Venta::class;
protected static ?string $navigationIcon = 'heroicon-o-chart-bar';
protected static ?string $navigationGroup = 'Reportes';
protected static ?string $modelLabel = 'Informe';
protected static ?string $pluralModelLabel = 'Informes';
protected static ?string $navigationLabel = 'Promedio Ponderado';
public static function canViewAny(): bool
{
return true; // Permitir acceso básico, se puede personalizar según necesidades
}
public static function canCreate(): bool
{
return false; // No se pueden crear informes
}
public static function canEdit($record): bool
{
return false; // No se pueden editar informes
}
public static function canDelete($record): bool
{
return false; // No se pueden eliminar informes
}
public static function table(Table $table): Table
{
return $table
->query(self::getInformeQuery())
->columns([
Tables\Columns\TextColumn::make('producto_nombre')
->label('Producto')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('categoria_nombre')
->label('Categoría')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('total_vendido')
->label('Cantidad Vendida')
->alignCenter()
->sortable()
->formatStateUsing(function ($state, $record) {
$unidadMedida = $record->unidad_medida ?? 'unidad';
$cantidadEnUnidad = Producto::convertirDesdeUnidades($state, $unidadMedida);
$nombreUnidad = explode(' ', Producto::getUnidadesMedida()[$unidadMedida])[0];
return "{$cantidadEnUnidad} {$nombreUnidad}";
})
->tooltip(function ($state, $record) {
$unidadMedida = $record->unidad_medida ?? 'unidad';
$cantidadEnUnidad = Producto::convertirDesdeUnidades($state, $unidadMedida);
return "Cantidad en unidades: {$state}\nCantidad en {$unidadMedida}: {$cantidadEnUnidad}";
}),
Tables\Columns\TextColumn::make('precio_compra_unitario')
->label('Precio Compra Actual')
->money('COP')
->alignEnd()
->sortable(),
Tables\Columns\TextColumn::make('promedio_ponderado_compra')
->label('Promedio Ponderado Compra')
->money('COP')
->alignEnd()
->sortable()
->tooltip('Precio promedio de compra ponderado por cantidades compradas'),
Tables\Columns\TextColumn::make('valor_total_compra')
->label('Valor Total Compra')
->money('COP')
->alignEnd()
->sortable(),
Tables\Columns\TextColumn::make('precio_venta_promedio')
->label('Precio Venta Promedio')
->money('COP')
->alignEnd()
->sortable(),
Tables\Columns\TextColumn::make('promedio_ponderado')
->label('Promedio Ponderado')
->money('COP')
->alignEnd()
->sortable()
->tooltip('Precio promedio ponderado por cantidad vendida'),
Tables\Columns\TextColumn::make('margen_beneficio')
->label('Margen %')
->alignCenter()
->sortable()
->getStateUsing(function ($record) {
$precioCompra = $record->precio_compra_unitario;
$precioVenta = $record->precio_venta_promedio;
if ($precioCompra > 0) {
$margen = (($precioVenta - $precioCompra) / $precioCompra) * 100;
return round($margen, 2);
}
return 0;
})
->formatStateUsing(function ($state) {
$color = $state >= 0 ? 'success' : 'danger';
$icon = $state >= 0 ? '📈' : '📉';
return "{$icon} {$state}%";
}),
])
->defaultSort('valor_total_compra', 'desc')
->filters([
Tables\Filters\Filter::make('fecha_rango')
->form([
Forms\Components\DatePicker::make('fecha_desde')
->label('Fecha Desde')
->default(Carbon::now()->startOfMonth()),
Forms\Components\DatePicker::make('fecha_hasta')
->label('Fecha Hasta')
->default(Carbon::now()->endOfMonth()),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['fecha_desde'],
fn (Builder $query, $date): Builder => $query->whereDate('ventas.created_at', '>=', $date),
)
->when(
$data['fecha_hasta'],
fn (Builder $query, $date): Builder => $query->whereDate('ventas.created_at', '<=', $date),
);
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['fecha_desde']) {
$indicators['fecha_desde'] = 'Desde: ' . Carbon::parse($data['fecha_desde'])->format('d/m/Y');
}
if ($data['fecha_hasta']) {
$indicators['fecha_hasta'] = 'Hasta: ' . Carbon::parse($data['fecha_hasta'])->format('d/m/Y');
}
return $indicators;
}),
Tables\Filters\SelectFilter::make('categoria')
->options(\App\Models\Categoria::pluck('nombre', 'id'))
->query(function (Builder $query, array $data): Builder {
if (!empty($data['value'])) {
return $query->where('categorias.id', $data['value']);
}
return $query;
})
->searchable()
->preload(),
])
->actions([
// No actions needed for reports
])
->bulkActions([
// No bulk actions for reports
])
->emptyStateHeading('No hay ventas en el período seleccionado')
->emptyStateDescription('Ajuste los filtros de fecha para ver resultados')
->poll('60s'); // Actualizar cada minuto
}
/**
* Query personalizado para el informe de promedio ponderado
*/
protected static function getInformeQuery(): Builder
{
return DetalleVenta::query()
->select([
'productos.id as id', // Filament necesita 'id' como clave del registro
'productos.id as producto_id', // Mantener para compatibilidad
'productos.nombre as producto_nombre',
'productos.precio_compra as precio_compra_unitario',
'productos.unidad_medida',
'categorias.nombre as categoria_nombre',
DB::raw('SUM(detalle_ventas.cantidad) as total_vendido'),
DB::raw('ROUND(AVG(detalle_ventas.precio_unitario), 2) as precio_venta_promedio'),
// Promedio ponderado real: suma de (cantidad * precio) / suma de cantidades
DB::raw('ROUND(SUM(detalle_ventas.cantidad * detalle_ventas.precio_unitario) / SUM(detalle_ventas.cantidad), 2) as promedio_ponderado'),
// Promedio ponderado de compra basado en compras reales
DB::raw('COALESCE(
(SELECT ROUND(SUM(dc.cantidad * dc.precio_unitario) / SUM(dc.cantidad), 2)
FROM detalle_compras dc
INNER JOIN compras c ON dc.compra_id = c.id
WHERE dc.producto_id = productos.id AND c.estado = \'Recibida\'),
CAST(productos.precio_compra AS DECIMAL(10,2))
) as promedio_ponderado_compra'),
DB::raw('COUNT(DISTINCT ventas.id) as numero_ventas'),
DB::raw('MIN(ventas.created_at) as primera_venta'),
DB::raw('MAX(ventas.created_at) as ultima_venta'),
// Agregar el campo calculado para poder ordenar por él con cast explícito
DB::raw('SUM(detalle_ventas.cantidad) * CAST(productos.precio_compra AS DECIMAL(10,2)) as valor_total_compra'),
])
->join('productos', 'detalle_ventas.producto_id', '=', 'productos.id')
->join('categorias', 'productos.categoria_id', '=', 'categorias.id')
->join('ventas', 'detalle_ventas.venta_id', '=', 'ventas.id')
->groupBy([
'productos.id',
'productos.nombre',
'productos.precio_compra',
'productos.unidad_medida',
'categorias.nombre'
])
->havingRaw('SUM(detalle_ventas.cantidad) > 0');
}
public static function getPages(): array
{
return [
'index' => Pages\ListInformes::route('/'),
];
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Filament\Resources\InformeResource\Pages;
use App\Filament\Resources\InformeResource;
use App\Models\DetalleVenta;
use App\Models\Producto;
use Filament\Resources\Pages\ListRecords;
use Filament\Actions;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ListInformes extends ListRecords
{
protected static string $resource = InformeResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('resumen')
->label('Ver Resumen')
->icon('heroicon-o-chart-pie')
->color('info')
->modalHeading('Resumen Ejecutivo - Promedio Ponderado')
->modalContent(function () {
return view('filament.pages.informe-resumen', [
'resumen' => $this->getResumenEjecutivo()
]);
})
->modalWidth('6xl'),
];
}
protected function getHeaderWidgets(): array
{
return [
InformeResource\Widgets\ResumenPromedioWidget::class,
];
}
/**
* Calcular resumen ejecutivo del período
*/
protected function getResumenEjecutivo(): array
{
$filtros = request()->get('tableFilters', []);
$fechaDesde = $filtros['fecha_rango']['fecha_desde'] ?? Carbon::now()->startOfMonth()->format('Y-m-d');
$fechaHasta = $filtros['fecha_rango']['fecha_hasta'] ?? Carbon::now()->endOfMonth()->format('Y-m-d');
$query = DetalleVenta::query()
->join('productos', 'detalle_ventas.producto_id', '=', 'productos.id')
->join('ventas', 'detalle_ventas.venta_id', '=', 'ventas.id')
->whereDate('ventas.created_at', '>=', $fechaDesde)
->whereDate('ventas.created_at', '<=', $fechaHasta);
// Cálculos principales
$totalProductosVendidos = $query->sum('detalle_ventas.cantidad');
$valorTotalCompra = $query->sum(DB::raw('detalle_ventas.cantidad * CAST(productos.precio_compra AS DECIMAL)'));
$valorTotalVenta = $query->sum(DB::raw('detalle_ventas.cantidad * detalle_ventas.precio_unitario'));
$promedioCompra = $totalProductosVendidos > 0
? $valorTotalCompra / $totalProductosVendidos
: 0;
$promedioVenta = $totalProductosVendidos > 0
? $valorTotalVenta / $totalProductosVendidos
: 0;
$margenTotal = $valorTotalCompra > 0
? (($valorTotalVenta - $valorTotalCompra) / $valorTotalCompra) * 100
: 0;
// Top productos por valor de compra
$topProductos = DetalleVenta::query()
->select([
'productos.nombre',
DB::raw('SUM(detalle_ventas.cantidad) as cantidad_vendida'),
DB::raw('SUM(detalle_ventas.cantidad * CAST(productos.precio_compra AS DECIMAL)) as valor_compra_total'),
'productos.precio_compra'
])
->join('productos', 'detalle_ventas.producto_id', '=', 'productos.id')
->join('ventas', 'detalle_ventas.venta_id', '=', 'ventas.id')
->whereDate('ventas.created_at', '>=', $fechaDesde)
->whereDate('ventas.created_at', '<=', $fechaHasta)
->groupBy('productos.id', 'productos.nombre', 'productos.precio_compra')
->orderByRaw('SUM(detalle_ventas.cantidad * CAST(productos.precio_compra AS DECIMAL)) desc')
->limit(10)
->get();
// Análisis por categoría
$categorias = DetalleVenta::query()
->select([
'categorias.nombre as categoria',
DB::raw('SUM(detalle_ventas.cantidad) as cantidad_vendida'),
DB::raw('SUM(detalle_ventas.cantidad * CAST(productos.precio_compra AS DECIMAL)) as valor_compra_total'),
DB::raw('AVG(CAST(productos.precio_compra AS DECIMAL)) as precio_compra_promedio'),
DB::raw('COUNT(DISTINCT productos.id) as productos_diferentes'),
])
->join('productos', 'detalle_ventas.producto_id', '=', 'productos.id')
->join('categorias', 'productos.categoria_id', '=', 'categorias.id')
->join('ventas', 'detalle_ventas.venta_id', '=', 'ventas.id')
->whereDate('ventas.created_at', '>=', $fechaDesde)
->whereDate('ventas.created_at', '<=', $fechaHasta)
->groupBy('categorias.id', 'categorias.nombre')
->orderByRaw('SUM(detalle_ventas.cantidad * CAST(productos.precio_compra AS DECIMAL)) desc')
->get();
return [
'periodo' => [
'fecha_desde' => Carbon::parse($fechaDesde)->format('d/m/Y'),
'fecha_hasta' => Carbon::parse($fechaHasta)->format('d/m/Y'),
'dias' => Carbon::parse($fechaDesde)->diffInDays(Carbon::parse($fechaHasta)) + 1,
],
'totales' => [
'productos_vendidos' => $totalProductosVendidos,
'valor_total_compra' => $valorTotalCompra,
'valor_total_venta' => $valorTotalVenta,
'promedio_compra' => $promedioCompra,
'promedio_venta' => $promedioVenta,
'margen_total' => $margenTotal,
'beneficio_total' => $valorTotalVenta - $valorTotalCompra,
],
'top_productos' => $topProductos,
'categorias' => $categorias,
];
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Filament\Resources\InformeResource\Widgets;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use App\Models\DetalleVenta;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ResumenPromedioWidget extends BaseWidget
{
protected function getStats(): array
{
$filtros = request()->get('tableFilters', []);
$fechaDesde = $filtros['fecha_rango']['fecha_desde'] ?? Carbon::now()->startOfMonth()->format('Y-m-d');
$fechaHasta = $filtros['fecha_rango']['fecha_hasta'] ?? Carbon::now()->endOfMonth()->format('Y-m-d');
$resumen = $this->calcularResumen($fechaDesde, $fechaHasta);
return [
Stat::make('Total Productos Vendidos', number_format($resumen['total_productos']))
->description('Unidades vendidas en el período')
->descriptionIcon('heroicon-m-cube')
->color('primary'),
Stat::make('Promedio Ponderado Compra', '$' . number_format($resumen['promedio_compra'], 0))
->description('Costo promedio por unidad')
->descriptionIcon('heroicon-m-banknotes')
->color('warning'),
Stat::make('Valor Total Invertido', '$' . number_format($resumen['valor_total_compra'], 0))
->description('Inversión total en productos vendidos')
->descriptionIcon('heroicon-m-chart-bar')
->color('danger'),
Stat::make('Margen Promedio', round($resumen['margen_promedio'], 2) . '%')
->description($resumen['margen_promedio'] >= 0 ? 'Beneficio promedio' : 'Pérdida promedio')
->descriptionIcon($resumen['margen_promedio'] >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
->color($resumen['margen_promedio'] >= 0 ? 'success' : 'danger'),
];
}
protected function calcularResumen(string $fechaDesde, string $fechaHasta): array
{
$query = DetalleVenta::query()
->join('productos', 'detalle_ventas.producto_id', '=', 'productos.id')
->join('ventas', 'detalle_ventas.venta_id', '=', 'ventas.id')
->whereDate('ventas.created_at', '>=', $fechaDesde)
->whereDate('ventas.created_at', '<=', $fechaHasta);
$totalProductos = $query->sum('detalle_ventas.cantidad');
$valorTotalCompra = $query->sum(DB::raw('detalle_ventas.cantidad * CAST(productos.precio_compra AS DECIMAL)'));
$valorTotalVenta = $query->sum(DB::raw('detalle_ventas.cantidad * detalle_ventas.precio_unitario'));
$promedioCompra = $totalProductos > 0 ? $valorTotalCompra / $totalProductos : 0;
$margenPromedio = $valorTotalCompra > 0 ? (($valorTotalVenta - $valorTotalCompra) / $valorTotalCompra) * 100 : 0;
return [
'total_productos' => $totalProductos,
'valor_total_compra' => $valorTotalCompra,
'promedio_compra' => $promedioCompra,
'margen_promedio' => $margenPromedio,
];
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\MovimientoCajaResource\Pages;
use App\Filament\Resources\MovimientoCajaResource\RelationManagers;
use App\Models\MovimientoCaja;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Filament\Forms\Components\Select;
class MovimientoCajaResource extends Resource
{
protected static ?string $model = MovimientoCaja::class;
protected static ?string $navigationIcon = 'heroicon-o-archive-box';
public static function canViewAny(): bool
{
return auth()->user()->can('ver movimientos de caja');
}
public static function form(Form $form): Form
{
return $form
->schema([
Select::make('caja_id')
->label('Caja (Fecha de apertura)')
->options(function () {
return \App\Models\Caja::where('estado', 'Abierta')
->pluck('created_at', 'id')
->mapWithKeys(function ($createdAt, $id) {
return [$id => \Carbon\Carbon::parse($createdAt)->format('d/m/Y H:i')];
});
})
->required(),
Forms\Components\TextInput::make('tipo')
->required(),
Forms\Components\TextInput::make('monto')
->required()
->numeric(),
Forms\Components\Textarea::make('descripcion')
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('caja.created_at')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('tipo'),
Tables\Columns\TextColumn::make('monto')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListMovimientoCajas::route('/'),
'create' => Pages\CreateMovimientoCaja::route('/create'),
'edit' => Pages\EditMovimientoCaja::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\MovimientoCajaResource\Pages;
use App\Filament\Resources\MovimientoCajaResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateMovimientoCaja extends CreateRecord
{
protected static string $resource = MovimientoCajaResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\MovimientoCajaResource\Pages;
use App\Filament\Resources\MovimientoCajaResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditMovimientoCaja extends EditRecord
{
protected static string $resource = MovimientoCajaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\MovimientoCajaResource\Pages;
use App\Filament\Resources\MovimientoCajaResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListMovimientoCajas extends ListRecords
{
protected static string $resource = MovimientoCajaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PermissionResource\Pages;
use App\Models\Permission;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class PermissionResource extends Resource
{
protected static ?string $model = Permission::class;
protected static ?string $navigationGroup = 'Administración';
protected static ?string $navigationLabel = 'Permisos';
protected static ?string $navigationIcon = 'heroicon-o-lock-closed';
public static function canViewAny(): bool
{
return auth()->user()->can('ver permisos');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->unique()
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('name')->sortable()->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make(),
]);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPermissions::route('/'),
'create' => Pages\CreatePermission::route('/create'),
'edit' => Pages\EditPermission::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\PermissionResource\Pages;
use App\Filament\Resources\PermissionResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreatePermission extends CreateRecord
{
protected static string $resource = PermissionResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PermissionResource\Pages;
use App\Filament\Resources\PermissionResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPermission extends EditRecord
{
protected static string $resource = PermissionResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PermissionResource\Pages;
use App\Filament\Resources\PermissionResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPermissions extends ListRecords
{
protected static string $resource = PermissionResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,256 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ProductVariantResource\Pages;
use App\Filament\Resources\ProductVariantResource\RelationManagers;
use App\Models\ProductVariant;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Actions\Action;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
use Filament\Tables\Filters\SelectFilter;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Auth;
class ProductVariantResource extends Resource
{
protected static ?string $navigationGroup = 'Inventario'; //
// Cambia el nombre en la navegación y en la vista del CRUD
public static function getModelLabel(): string
{
return 'Variante de producto'; // Nombre singular
}
public static function getPluralModelLabel(): string
{
return 'Variantes de productos'; // Nombre plural
}
public static function getNavigationLabel(): string
{
return 'Variante de productos'; // Nombre en el menú de navegación
}
public static function canViewAny(): bool
{
return Auth::user()->can('ver variantes');
}
protected static ?string $model = ProductVariant::class;
protected static ?string $navigationIcon = 'heroicon-o-tag';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Select::make('producto_id')
->relationship('producto', 'nombre')
->searchable()
->required()
->reactive()
->afterStateUpdated(fn($set, $get, $component) => self::updateSkuAndBarcode($set, $get, $component)),
Forms\Components\Select::make('color_id')
->relationship('color', 'name')
->searchable()
->required()
->reactive()
->afterStateUpdated(fn($set, $get, $component) => self::updateSkuAndBarcode($set, $get, $component)),
Forms\Components\Select::make('size_id')
->relationship('size', 'name')
->searchable()
->required()
->reactive()
->afterStateUpdated(fn($set, $get, $component) => self::updateSkuAndBarcode($set, $get, $component)),
Forms\Components\TextInput::make('sku')
->label('SKU')
->required()
->maxLength(50)
->dehydrated(), // Se guarda en la base de datos
TextInput::make('barcode')
->label('Código de Barras')
->required()
->length(13)
->dehydrated()
->suffixAction(
Action::make('imprimirQr')
->icon('heroicon-o-printer')
->color('primary')
->hidden(fn($record) => is_null($record))
->action(fn($state) => redirect()->route('imprimir.barcode', ['barcode' => $state]))
->hidden(fn($record) => is_null($record))
),
Forms\Components\Section::make('Asignación de Bodega')
->schema([
Forms\Components\Select::make('bodega_id')
->label('Bodega Inicial')
->options(\App\Models\Bodega::pluck('nombre', 'id'))
->required()
->searchable()
->preload()
->default(function () {
// Intentar obtener la bodega principal como default
$bodegaPrincipal = \App\Models\Bodega::where('nombre', 'Principal')->first();
return $bodegaPrincipal?->id;
})
->helperText('Seleccione la bodega donde se asignará el stock inicial de esta variante'),
Forms\Components\TextInput::make('stock_inicial')
->label('Stock Inicial')
->numeric()
->required()
->default(0)
->minValue(0)
->helperText('Stock que se asignará en la bodega seleccionada'),
Forms\Components\Placeholder::make('info_bodega')
->label('Información')
->content('La variante se creará con el stock especificado en la bodega seleccionada. Podrá distribuir a otras bodegas posteriormente.')
])
->columns(2)
->hiddenOn('edit'), // Solo mostrar en creación
]);
}
protected static function updateSkuAndBarcode($set, $get, $component)
{
$producto = $get('producto_id') ? \App\Models\Producto::find($get('producto_id')) : null;
$color = $get('color_id') ? \App\Models\Color::find($get('color_id')) : null;
$size = $get('size_id') ? \App\Models\Size::find($get('size_id')) : null;
$exists = ProductVariant::where('producto_id', $get('producto_id'))
->where('color_id', $get('color_id'))
->where('size_id', $get('size_id'))
->exists();
if ($exists) {
// Mostrar notificación en Filament
Notification::make()
->title('Error')
->body('Esta combinación de producto, color y talla ya existe.')
->danger()
->send();
}
// Generar SKU basado en el producto, color y talla
$baseSku = strtoupper(substr($producto?->nombre ?? 'XX', 0, 2)) . '-' .
strtoupper(substr($color?->name ?? 'XX', 0, 2)) . '-' .
strtoupper(substr($size?->name ?? 'XX', 0, 2));
// Asegurar SKU único
$sku = $baseSku;
$counter = 1;
while (ProductVariant::where('sku', $sku)->exists()) {
$sku = $baseSku . '-' . $counter;
$counter++;
}
// Obtener datos para el código de barras
$countryCode = '57'; // Código de país (puedes cambiarlo)
$categoryId = $producto?->categoria_id ?? 00;
$productId = $producto?->id ?? 00000;
$variantId = ProductVariant::where('producto_id',$productId)->count() ?? 0;
$variantId += 1;
// Generar código de barras estructurado
$eanService = app(\App\Services\EAN13Service::class);
$barcode = $eanService->generateUniqueBarcode($countryCode, $categoryId, $productId, $variantId);
// Asignar valores al formulario
$set('sku', $sku);
$set('barcode', $barcode);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('producto.nombre')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('color.name')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('size.name')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('stock_total')
->label('Stock Total')
->getStateUsing(function (ProductVariant $record): string {
$stockTotal = $record->getStockEfectivo();
$bodegas = $record->bodegas()->count();
return "{$stockTotal} ({$bodegas} bodegas)";
})
->tooltip('Stock total distribuido en todas las bodegas')
->sortable(query: function ($query, $direction) {
return $query->withSum('bodegas as stock_total', 'variante_bodega.stock')
->orderBy('stock_total', $direction);
}),
Tables\Columns\TextColumn::make('sku')
->label('SKU')
->searchable(),
Tables\Columns\TextColumn::make('barcode')
->action(fn($record) => redirect()->route('imprimir.barcode', ['barcode' => $record->barcode]))
->tooltip('Imprimir código de barras'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('producto_id')
->label('Producto')
->relationship('producto', 'nombre') // Relación con el modelo producto
->preload() // Precargar opcionesπ
->searchable(), // Permitir búsqueda en el filtro
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
RelationManagers\BodegasRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListProductVariants::route('/'),
'create' => Pages\CreateProductVariant::route('/create'),
'edit' => Pages\EditProductVariant::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Filament\Resources\ProductVariantResource\Pages;
use App\Filament\Resources\ProductVariantResource;
use App\Models\ProductVariant;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Log;
class CreateProductVariant extends CreateRecord
{
protected static string $resource = ProductVariantResource::class;
protected function handleRecordCreation(array $data): ProductVariant
{
// Debug: Log de datos recibidos
Log::info('Datos recibidos en createProductVariant:', $data);
// Extraer datos de bodega
$bodegaId = $data['bodega_id'] ?? null;
$stockInicial = $data['stock_inicial'] ?? 0;
Log::info('Bodega ID extraído:', ['bodega_id' => $bodegaId]);
Log::info('Stock inicial extraído:', ['stock_inicial' => $stockInicial]);
// Remover campos que no pertenecen al modelo ProductVariant
unset($data['bodega_id'], $data['stock_inicial']);
// Asegurar que stock esté en 0 en el modelo principal
$data['stock'] = 0;
// Crear la variante
$variante = ProductVariant::create($data);
Log::info('Variante creada:', ['id' => $variante->id]);
// Asignar a la bodega si se especificó
if ($bodegaId) {
$variante->bodegas()->attach($bodegaId, ['stock' => $stockInicial]);
Log::info('Relación bodega creada:', [
'variante_id' => $variante->id,
'bodega_id' => $bodegaId,
'stock' => $stockInicial
]);
$bodegaNombre = \App\Models\Bodega::find($bodegaId)->nombre;
Notification::make()
->title('Variante creada exitosamente')
->body("La variante se asignó a la bodega '{$bodegaNombre}' con stock de {$stockInicial} unidades.")
->success()
->send();
}
return $variante;
}
}
@@ -0,0 +1,21 @@
<?php
// App/Filament/Resources/ProductVariantResource/Pages/EditProductVariant.php
namespace App\Filament\Resources\ProductVariantResource\Pages;
use App\Filament\Resources\ProductVariantResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditProductVariant extends EditRecord
{
protected static string $resource = ProductVariantResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ProductVariantResource\Pages;
use App\Filament\Resources\ProductVariantResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListProductVariants extends ListRecords
{
protected static string $resource = ProductVariantResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,269 @@
<?php
namespace App\Filament\Resources\ProductVariantResource\RelationManagers;
use App\Models\Bodega;
use App\Models\ProductVariant;
use App\Services\TransferenciaBodegaService;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Notifications\Notification;
class BodegasRelationManager extends RelationManager
{
protected static string $relationship = 'bodegas';
protected static ?string $title = 'Stock por Bodega';
protected static ?string $modelLabel = 'Bodega';
protected static ?string $pluralModelLabel = 'Bodegas';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Select::make('bodega_id')
->label('Bodega')
->options(\App\Models\Bodega::pluck('nombre', 'id'))
->required()
->searchable()
->preload(),
Forms\Components\TextInput::make('stock')
->label('Stock en esta Bodega')
->numeric()
->required()
->default(0)
->minValue(0)
->helperText('Cantidad de esta variante en la bodega seleccionada'),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('nombre')
->columns([
Tables\Columns\TextColumn::make('nombre')
->label('Bodega')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('pivot.stock')
->label('Stock')
->alignCenter()
->sortable()
->formatStateUsing(function ($state) {
$estado = $state > 0 ? '✅' : '⚠️';
return "{$estado} {$state} unidades";
})
->tooltip(function ($state) {
return "Stock en unidades: {$state}";
}),
Tables\Columns\TextColumn::make('pivot.updated_at')
->label('Última Actualización')
->dateTime('d/m/Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\Filter::make('con_stock')
->label('Solo con Stock')
->query(fn($query) => $query->where('variante_bodega.stock', '>', 0)),
])
->headerActions([
Tables\Actions\CreateAction::make()
->label('Asignar a Bodega')
->modalHeading('Asignar Variante a Bodega')
->action(function (array $data): void {
$variante = $this->getOwnerRecord();
$bodegaId = $data['bodega_id'];
$stock = $data['stock'];
// Verificar si ya existe la relación
if ($variante->bodegas()->where('bodega_id', $bodegaId)->exists()) {
Notification::make()
->warning()
->title('Variante ya asignada')
->body('Esta variante ya está asignada a esa bodega. Use la opción de editar.')
->send();
return;
}
// Crear la relación
$variante->bodegas()->attach($bodegaId, ['stock' => $stock]);
Notification::make()
->success()
->title('Variante asignada')
->body('La variante ha sido asignada a la bodega correctamente.')
->send();
}),
Tables\Actions\Action::make('transferir_stock')
->label('Transferir entre Bodegas')
->icon('heroicon-o-arrows-right-left')
->color('info')
->form([
Forms\Components\Placeholder::make('info')
->label('Información de la Variante')
->content(function () {
$variante = $this->getOwnerRecord();
return "Variante: {$variante->sku} | Stock Total: {$variante->getStockEfectivo()} unidades";
}),
Forms\Components\Select::make('bodega_origen_id')
->label('Bodega Origen')
->options(function () {
$variante = $this->getOwnerRecord();
return $variante->bodegas()
->where('variante_bodega.stock', '>', 0)
->pluck('nombre', 'bodegas.id');
})
->required()
->searchable()
->reactive()
->afterStateUpdated(function ($set, $get, $state) {
if ($state) {
$variante = $this->getOwnerRecord();
$stock = $variante->bodegas()
->where('bodegas.id', $state)
->first()?->pivot?->stock ?? 0;
$set('stock_disponible', $stock);
}
}),
Forms\Components\Placeholder::make('stock_disponible')
->label('Stock Disponible en Origen')
->content(fn($get) => ($get('stock_disponible') ?? 0) . ' unidades'),
Forms\Components\Select::make('bodega_destino_id')
->label('Bodega Destino')
->options(Bodega::pluck('nombre', 'id'))
->required()
->searchable()
->different('bodega_origen_id'),
Forms\Components\TextInput::make('cantidad')
->label('Cantidad a Transferir')
->numeric()
->required()
->minValue(1)
->maxValue(function ($get) {
return $get('stock_disponible') ?? 0;
}),
Forms\Components\TextInput::make('motivo')
->label('Motivo de la Transferencia')
->placeholder('Ej: Reposición, reorganización de inventario...')
->maxLength(255),
])
->action(function (array $data): void {
$variante = $this->getOwnerRecord();
$service = new TransferenciaBodegaService();
try {
$transferencia = $service->transferirVariante(
$variante->id,
$data['bodega_origen_id'],
$data['bodega_destino_id'],
$data['cantidad'],
$data['motivo'] ?? null
);
Notification::make()
->success()
->title('Transferencia Exitosa')
->body("Se transfirieron {$data['cantidad']} unidades entre bodegas.")
->send();
} catch (\Exception $e) {
Notification::make()
->danger()
->title('Error en Transferencia')
->body($e->getMessage())
->send();
}
}),
])
->actions([
Tables\Actions\EditAction::make()
->label('Editar Stock')
->form([
Forms\Components\TextInput::make('stock')
->label('Stock en esta Bodega')
->numeric()
->required()
->minValue(0)
->helperText('Cantidad de esta variante en la bodega'),
])
->fillForm(function ($record): array {
return [
'stock' => $record->pivot?->stock ?? 0,
];
})
->using(function (array $data, $record): void {
// Actualizamos el pivot directamente
$record->pivot->update(['stock' => (int)$data['stock']]);
// Notificación
Notification::make()
->success()
->title('Stock Actualizado')
->body("El stock se actualizó a {$data['stock']} unidades.")
->send();
}),
Tables\Actions\Action::make('vaciar_stock')
->label('Vaciar Stock')
->icon('heroicon-o-minus-circle')
->color('warning')
->requiresConfirmation()
->modalHeading('Vaciar Stock de Bodega')
->modalDescription('¿Estás seguro de que quieres poner el stock en 0 para esta bodega? La bodega seguirá asignada a la variante.')
->modalSubmitActionLabel('Sí, vaciar stock')
->action(function ($record): void {
$record->pivot->update(['stock' => 0]);
Notification::make()
->success()
->title('Stock Vaciado')
->body('El stock de la bodega ha sido puesto en 0.')
->send();
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('vaciar_stock_multiple')
->label('Vaciar Stock de Seleccionadas')
->icon('heroicon-o-minus-circle')
->color('warning')
->requiresConfirmation()
->modalHeading('Vaciar Stock de Bodegas Seleccionadas')
->modalDescription('¿Estás seguro de que quieres poner el stock en 0 para todas las bodegas seleccionadas?')
->action(function ($records): void {
foreach ($records as $record) {
$record->pivot->update(['stock' => 0]);
}
Notification::make()
->success()
->title('Stock Vaciado')
->body('El stock de las bodegas seleccionadas ha sido puesto en 0.')
->send();
}),
Tables\Actions\DeleteBulkAction::make()
->label('Quitar de Bodegas Seleccionadas')
->requiresConfirmation()
->modalHeading('Quitar Variante de Bodegas')
->modalDescription('¿Estás seguro de que quieres quitar completamente esta variante de las bodegas seleccionadas? Se perderá toda la información de stock.')
->modalSubmitActionLabel('Sí, quitar completamente'),
]),
]);
}
}
+658
View File
@@ -0,0 +1,658 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ProductoResource\Pages;
use App\Filament\Resources\ProductoResource\RelationManagers;
use App\Models\Producto;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Actions\DeleteAction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Filament\Tables\Filters\SelectFilter;
use App\Filament\Resources\ProductoResource\RelationManagers\VariantesRelationManager;
use App\Filament\Resources\ProductoResource\RelationManagers\BodegasRelationManager;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Actions\Action;
use App\Models\ProductVariant;
use Illuminate\Validation\Rule;
use Filament\Notifications\Notification;
use App\Exports\ProductosExport;
use App\Imports\ProductosImport;
use Maatwebsite\Excel\Facades\Excel;
use Filament\Forms\Components\FileUpload;
class ProductoResource extends Resource
{
protected static ?string $navigationGroup = 'Inventario'; //
protected static ?string $model = Producto::class;
protected static string $relationship = 'variantes';
protected static ?string $navigationIcon = 'heroicon-o-cube';
public static function canViewAny(): bool
{
return auth()->user()->can('ver productos');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('nombre')
->required()
->maxLength(100),
// Campo para subir imagen
Forms\Components\FileUpload::make('imagen')
->label('Imagen del Producto')
->image()
->directory(directory: 'productos')
->maxSize(10240)
->acceptedFileTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp']),
Forms\Components\Textarea::make('descripcion')
->columnSpanFull()
->required()
->placeholder('Ingrese una descripción del producto')
->default(''),
TextInput::make('precio_compra')
->required()
->numeric()
->maxLength(255)
->placeholder('Ej: 1000')
->lazy(), // Cambiar de reactive() a lazy()
TextInput::make('precio_venta')
->required()
->numeric()
->maxLength(255)
->lazy() // Cambiar de reactive() a lazy()
->placeholder('Ej: 1200')
->afterStateUpdated(function (callable $get, callable $set) {
$precioCompra = $get('precio_compra');
$precioVenta = $get('precio_venta');
// Solo validar si ambos valores están presentes y son significativos
if (!is_null($precioCompra) && !is_null($precioVenta) &&
$precioCompra > 0 && $precioVenta > 0 &&
$precioVenta < $precioCompra) {
Notification::make()
->warning()
->title('Advertencia')
->body('El precio de venta es menor al precio de compra.')
->persistent()
->send();
}
}),
Forms\Components\TextInput::make('stock')
->required()
->numeric()
->default(0)
->lazy() // Cambiar de live() a lazy()
->dehydrated()
->hidden(fn($get, $record) => $record && $record->variants()->exists()) // Oculta si hay variantes
->disabled(fn($get) => $get('producto_id') && \App\Models\Producto::find($get('producto_id'))->variants()->exists())
->visible(false) // Oculto - se maneja automáticamente
->label('Stock (Unidades Base)'),
Forms\Components\TextInput::make('stock_entrada')
->label('Stock Inicial')
->numeric()
->default(0)
->lazy() // Cambiar de live() a lazy() para evitar actualizaciones en tiempo real
->dehydrated(true) // Permitir que se envíe al servidor
->hidden(fn($get, $record) => $record && $record->variants()->exists())
->helperText(fn($get, $record) => $record && $record->variants()->exists()
? 'El stock se maneja a nivel de variantes'
: 'Ingrese el stock inicial del producto')
->afterStateUpdated(function (callable $get, callable $set) {
$stockEntrada = $get('stock_entrada') ?? 0;
$unidadMedida = $get('unidad_medida') ?? 'unidad';
// Asegurar que la cantidad sea float (Filament a veces pasa strings desde inputs)
$stockEntradaFloat = is_numeric($stockEntrada) ? (float) $stockEntrada : 0.0;
$stockUnidades = Producto::convertirAUnidades($stockEntradaFloat, $unidadMedida);
$set('stock', $stockUnidades);
})
->hint(function (callable $get) {
$unidadMedida = $get('unidad_medida') ?? 'unidad';
$factor = Producto::getFactorConversion($unidadMedida);
return $factor > 1 ? "Se convertirá automáticamente (×{$factor})" : null;
}),
Forms\Components\TextInput::make('stock_minimo')
->required()
->numeric()
->default(0)
->lazy() // Cambiar de live() a lazy()
->dehydrated()
->visible(false) // Oculto - se maneja automáticamente
->label('Stock Mínimo (Unidades Base)')
->afterStateUpdated(function (callable $get, callable $set) {
$stockMinimo = $get('stock_minimo');
$stockMaximo = $get('stock_maximo');
// Solo validar si hay valores significativos
if (!is_null($stockMinimo) && !is_null($stockMaximo) &&
$stockMinimo > 0 && $stockMaximo > 0 &&
$stockMaximo < $stockMinimo) {
Notification::make()
->warning()
->title('Advertencia')
->body('El stock máximo debe ser mayor al stock mínimo.')
->persistent()
->send();
}
}),
Forms\Components\TextInput::make('stock_minimo_entrada')
->label('Stock Mínimo')
->numeric()
->default(0)
->lazy() // Cambiar de live() a lazy() para evitar actualizaciones en tiempo real
->dehydrated(true) // Permitir que se envíe al servidor
->hidden(fn($get, $record) => $record && $record->variants()->exists())
->helperText(fn($get, $record) => $record && $record->variants()->exists()
? 'El stock mínimo se configura por variante'
: 'Configure el stock mínimo para alertas')
->afterStateUpdated(function (callable $get, callable $set) {
$stockMinimoEntrada = $get('stock_minimo_entrada') ?? 0;
$unidadMedida = $get('unidad_medida') ?? 'unidad';
$stockMinimoEntradaFloat = is_numeric($stockMinimoEntrada) ? (float) $stockMinimoEntrada : 0.0;
$stockMinimoUnidades = Producto::convertirAUnidades($stockMinimoEntradaFloat, $unidadMedida);
$set('stock_minimo', $stockMinimoUnidades);
// Solo validar si hay un valor significativo para evitar notificaciones innecesarias
if ($stockMinimoEntradaFloat > 0) {
$stockMaximo = $get('stock_maximo');
if (!is_null($stockMaximo) && $stockMaximo < $stockMinimoUnidades) {
Notification::make()
->warning()
->title('Advertencia')
->body('El stock máximo debe ser mayor al stock mínimo.')
->persistent()
->send();
}
}
})
->hint(function (callable $get) {
$unidadMedida = $get('unidad_medida') ?? 'unidad';
$factor = Producto::getFactorConversion($unidadMedida);
return $factor > 1 ? "Se convertirá automáticamente (×{$factor})" : null;
}),
Forms\Components\TextInput::make('stock_maximo')
->required()
->numeric()
->default(0)
->lazy() // Cambiar de live() a lazy()
->dehydrated()
->visible(false) // Oculto - se maneja automáticamente
->label('Stock Máximo (Unidades Base)')
->afterStateUpdated(function (callable $get, callable $set) {
$stockMinimo = $get('stock_minimo');
$stockMaximo = $get('stock_maximo');
// Solo validar si hay valores significativos
if (!is_null($stockMinimo) && !is_null($stockMaximo) &&
$stockMinimo > 0 && $stockMaximo > 0 &&
$stockMaximo < $stockMinimo) {
Notification::make()
->warning()
->title('Advertencia')
->body('El stock máximo debe ser mayor al stock mínimo.')
->persistent()
->send();
}
}),
Forms\Components\TextInput::make('stock_maximo_entrada')
->label('Stock Máximo')
->numeric()
->default(function (callable $get) {
$unidadMedida = $get('unidad_medida') ?? 'unidad';
return Producto::convertirDesdeUnidades(1000, $unidadMedida);
})
->lazy() // Cambiar de live() a lazy() para evitar actualizaciones en tiempo real
->dehydrated(true) // Permitir que se envíe al servidor
->hidden(fn($get, $record) => $record && $record->variants()->exists())
->helperText(fn($get, $record) => $record && $record->variants()->exists()
? 'El stock máximo se configura por variante'
: 'Configure el stock máximo recomendado')
->afterStateUpdated(function (callable $get, callable $set) {
$stockMaximoEntrada = $get('stock_maximo_entrada') ?? 0;
$unidadMedida = $get('unidad_medida') ?? 'unidad';
$stockMaximoEntradaFloat = is_numeric($stockMaximoEntrada) ? (float) $stockMaximoEntrada : 0.0;
$stockMaximoUnidades = Producto::convertirAUnidades($stockMaximoEntradaFloat, $unidadMedida);
$set('stock_maximo', $stockMaximoUnidades);
// Solo validar si hay un valor significativo para evitar notificaciones innecesarias
if ($stockMaximoEntradaFloat > 0) {
$stockMinimo = $get('stock_minimo');
if (!is_null($stockMinimo) && $stockMaximoUnidades < $stockMinimo) {
Notification::make()
->warning()
->title('Advertencia')
->body('El stock máximo debe ser mayor al stock mínimo.')
->persistent()
->send();
}
}
})
->hint(function (callable $get) {
$unidadMedida = $get('unidad_medida') ?? 'unidad';
$factor = Producto::getFactorConversion($unidadMedida);
return $factor > 1 ? "Se convertirá automáticamente (×{$factor})" : null;
}),
Forms\Components\Select::make('unidad_medida')
->label('Unidad de Medida')
->options(Producto::getUnidadesMedida())
->default('unidad')
->required()
->live()
->helperText('El sistema guardará todo en unidades individuales. Esta opción facilita la entrada de datos.')
->afterStateUpdated(function (callable $get, callable $set) {
$unidad = $get('unidad_medida');
$factor = Producto::getFactorConversion($unidad);
// Actualizar los campos auxiliares basados en los valores actuales en unidades
$stockActual = $get('stock') ?? 0;
$stockMinimo = $get('stock_minimo') ?? 0;
$stockMaximo = $get('stock_maximo') ?? 1000;
$set('stock_entrada', Producto::convertirDesdeUnidades($stockActual, $unidad));
$set('stock_minimo_entrada', Producto::convertirDesdeUnidades($stockMinimo, $unidad));
$set('stock_maximo_entrada', Producto::convertirDesdeUnidades($stockMaximo, $unidad));
if ($factor > 1) {
Notification::make()
->info()
->title('Conversión de Unidades')
->body("1 {$unidad} = {$factor} unidades individuales")
->send();
}
}),
Forms\Components\Select::make('categoria_id')
->relationship('categoria', 'nombre')
->searchable()
->required()
->lazy() // Cambiar de reactive() a lazy()
->afterStateUpdated(fn($set, $get) => ProductoResource::updateSkuAndBarcode($set, $get)),
TextInput::make('codigo_barras')
->label('Código de Barras')
->length(13)
->lazy() // Cambiar de live() a lazy()
->dehydrated()
->hidden(fn($get, $record) => $record && $record->variants()->exists()) // Oculta si hay variantes
->disabled(fn($get) => $get('producto_id') && \App\Models\Producto::find($get('producto_id'))->variants()->exists())
->suffixAction(
Action::make('imprimirQr')
->icon('heroicon-o-printer')
->color('primary')
->hidden(fn($record) => is_null($record)) // Oculta el botón si no hay un registro
->disabled(fn($get) => empty($get('codigo_barras'))) // Deshabilita si no hay código de barras
->action(fn($state) => redirect()->route('imprimir.barcode', ['barcode' => $state])) // Redirige a la impresión
->visible(fn($get, $record) => $record && !$record->variants()->exists()) // Muestra si el producto no tiene variantes
),
Forms\Components\Toggle::make('estado')
->label('Estado')
->default(true)
->required(),
Forms\Components\Hidden::make('producto_id')
->default(fn($record) => $record->id ?? null),
Forms\Components\Hidden::make('categoria_id')
->default(fn($record) => $record->categoria_id ?? null),
]);
}
protected static function updateSkuAndBarcode($set, $get)
{
$countryCode = '57'; // Código de país
$categoryId = $get('categoria_id') ?? '00';
// Obtener el siguiente ID de producto
$nextProductId = Producto::max('id') + 1;
$variantId = '00'; // Si no hay variante, usamos 00
// Generar código de barras estructurado
$eanService = app(\App\Services\EAN13Service::class);
$barcode = $eanService->generateUniqueBarcode($countryCode, $categoryId, $nextProductId, $variantId);
// Asignar el código de barras al formulario
$set('codigo_barras', $barcode);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('nombre')
->searchable(),
Tables\Columns\TextColumn::make('codigo_barras')
->formatStateUsing(fn($record) => $record->variants()->exists() ? 'Tiene variantes' : $record->codigo_barras)
->action(fn($record) => !$record->variants()->exists() ? redirect()->route('imprimir.barcode', ['barcode' => $record->codigo_barras]) : null)
->tooltip(fn($record) => $record->variants()->exists() ? 'Este producto tiene variantes que contienen los Barcodes' : 'Imprimir código de barras'),
Tables\Columns\TextColumn::make('precio_compra')
->searchable(),
Tables\Columns\TextColumn::make('precio_venta')
->searchable(),
Tables\Columns\TextColumn::make('stock_total')
->label('Stock')
->getStateUsing(function ($record, \Livewire\Component $livewire) {
$filterState = $livewire->tableFilters ?? [];
$bodegaId = $filterState['bodegas']['value'] ?? null;
if ($bodegaId) {
return $record->getStockTotalEnBodega($bodegaId);
}
// Usar el método getStockEfectivo que considera bodegas, variantes y stock directo
return $record->getStockEfectivo();
})
->formatStateUsing(function ($state, $record) {
$totalStockUnidades = $state;
$ok = $totalStockUnidades >= $record->stock_minimo;
$icon = $ok ? '✅' : '⚠️';
// Mostrar stock en la unidad de medida configurada
$unidadMedida = $record->unidad_medida ?? 'unidad';
$stockEnUnidad = Producto::convertirDesdeUnidades($totalStockUnidades, $unidadMedida);
$nombreUnidad = Producto::getUnidadesMedida()[$unidadMedida] ?? 'Unidad (1)';
return "{$icon} {$stockEnUnidad} " . explode(' ', $nombreUnidad)[0];
})
->tooltip(function ($record, \Livewire\Component $livewire) {
$filterState = $livewire->tableFilters ?? [];
$bodegaId = $filterState['bodegas']['value'] ?? null;
if ($bodegaId) {
$totalStockUnidades = $record->getStockTotalEnBodega($bodegaId);
$bodegaNombre = \App\Models\Bodega::find($bodegaId)?->nombre ?? 'Bodega seleccionada';
$origen = "\nEn {$bodegaNombre}";
} else {
$totalStockUnidades = $record->getStockEfectivo();
$origen = '';
if ($record->bodegas()->exists()) {
$origen = "\nDistribuido en " . $record->bodegas()->count() . " bodega(s)";
} elseif ($record->variants()->exists()) {
$origen = "\nStock de variantes";
} else {
$origen = "\nStock directo";
}
}
$unidadMedida = $record->unidad_medida ?? 'unidad';
$stockEnUnidad = Producto::convertirDesdeUnidades($totalStockUnidades, $unidadMedida);
$nombreUnidad = Producto::getUnidadesMedida()[$unidadMedida] ?? 'Unidad (1)';
$mensaje = $totalStockUnidades < $record->stock_minimo
? "Stock por debajo del mínimo"
: "Stock suficiente";
return "{$mensaje}\nStock en {$nombreUnidad}: {$stockEnUnidad}\nStock en unidades: {$totalStockUnidades}{$origen}";
}),
Tables\Columns\TextColumn::make('stock_minimo')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('unidad_medida')
->label('Unidad')
->formatStateUsing(function ($state) {
$unidades = Producto::getUnidadesMedida();
return explode(' ', $unidades[$state ?? 'unidad'])[0];
})
->tooltip(function ($record) {
$unidades = Producto::getUnidadesMedida();
return $unidades[$record->unidad_medida ?? 'unidad'];
}),
Tables\Columns\TextColumn::make('categoria.nombre')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('estado')
->boolean(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('categoria_id')
->label('Categoría')
->relationship('categoria', 'nombre') // Relación con el modelo Categoría
->preload() // Precargar opciones
->searchable(), // Permitir búsqueda en el filtro
SelectFilter::make('bodegas')
->label('Bodega')
->relationship('bodegas', 'nombre')
->preload()
->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->before(function (Producto $record) {
// Verificar si el producto está siendo usado en compras
$comprasCount = $record->detalleCompras()->count();
$ventasCount = $record->detalleVentas()->count();
if ($comprasCount > 0 || $ventasCount > 0) {
Notification::make()
->warning()
->title('No se puede eliminar')
->body("Este producto está siendo usado en {$comprasCount} compras y {$ventasCount} ventas. Los registros se mantendrán con información histórica.")
->persistent()
->send();
}
}),
Tables\Actions\Action::make('transferir')
->label('Transferir Stock')
->icon('heroicon-o-arrow-path')
->color('warning')
->form([
Forms\Components\Select::make('bodega_origen_id')
->label('Bodega Origen')
->options(function (Producto $record) {
return $record->bodegas()
->wherePivot('stock', '>', 0)
->pluck('nombre', 'bodegas.id');
})
->required()
->reactive()
->helperText(function (callable $get, Producto $record) {
$bodegaId = $get('bodega_origen_id');
if (!$bodegaId) return null;
$bodega = $record->bodegas()->where('bodega_id', $bodegaId)->first();
$stock = $bodega ? $bodega->pivot->stock : 0;
return "Stock disponible: {$stock} unidades";
}),
Forms\Components\Select::make('bodega_destino_id')
->label('Bodega Destino')
->options(function (callable $get) {
$bodegaOrigenId = $get('bodega_origen_id');
$bodegas = \App\Models\Bodega::all()->pluck('nombre', 'id');
if ($bodegaOrigenId) {
$bodegas = $bodegas->except($bodegaOrigenId);
}
return $bodegas;
})
->required(),
Forms\Components\TextInput::make('cantidad')
->label('Cantidad')
->numeric()
->required()
->minValue(1)
->maxValue(function (callable $get, Producto $record) {
$bodegaId = $get('bodega_origen_id');
if (!$bodegaId) return 999999;
$bodega = $record->bodegas()->where('bodega_id', $bodegaId)->first();
return $bodega ? $bodega->pivot->stock : 0;
}),
Forms\Components\Textarea::make('motivo')
->label('Motivo')
->placeholder('Opcional')
->rows(2),
])
->action(function (Producto $record, array $data) {
try {
$service = new \App\Services\TransferenciaBodegaService();
$service->transferir(
$record->id,
$data['bodega_origen_id'],
$data['bodega_destino_id'],
$data['cantidad'],
$data['motivo'] ?? null
);
Notification::make()
->title('Transferencia realizada')
->body("Se transfirieron {$data['cantidad']} unidades de {$record->nombre}")
->success()
->send();
} catch (\Exception $e) {
Notification::make()
->title('Error en transferencia')
->body($e->getMessage())
->danger()
->send();
}
})
->visible(fn (Producto $record) => $record->bodegas()->wherePivot('stock', '>', 0)->exists()),
])
->headerActions([
Tables\Actions\Action::make('exportar')
->label('Exportar a Excel')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->action(function () {
return Excel::download(new ProductosExport, 'productos_' . date('Y-m-d_H-i-s') . '.xlsx');
}),
Tables\Actions\Action::make('importar')
->label('Importar desde Excel')
->icon('heroicon-o-arrow-up-tray')
->color('primary')
->form([
FileUpload::make('archivo')
->label('Archivo Excel')
->acceptedFileTypes([
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
])
->required()
->helperText('Formatos aceptados: .xlsx, .xls, .csv')
->disk('local')
->directory('imports'),
])
->action(function (array $data) {
try {
$import = new ProductosImport;
Excel::import($import, $data['archivo']);
$failures = $import->failures();
$errors = $import->errors();
if ($failures->isNotEmpty() || $errors->isNotEmpty()) {
$errorMessages = [];
foreach ($failures as $failure) {
$errorMessages[] = "Fila {$failure->row()}: " . implode(', ', $failure->errors());
}
foreach ($errors as $error) {
$errorMessages[] = $error->getMessage();
}
Notification::make()
->warning()
->title('Importación completada con errores')
->body('Algunos productos no se pudieron importar: ' . implode(' | ', array_slice($errorMessages, 0, 3)))
->persistent()
->send();
} else {
Notification::make()
->success()
->title('Importación exitosa')
->body('Los productos se importaron correctamente.')
->send();
}
} catch (\Exception $e) {
Notification::make()
->danger()
->title('Error en la importación')
->body('Ocurrió un error: ' . $e->getMessage())
->persistent()
->send();
}
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
VariantesRelationManager::class,
BodegasRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListProductos::route('/'),
'create' => Pages\CreateProducto::route('/create'),
'edit' => Pages\EditProducto::route('/{record}/edit'),
];
}
public static function getNavigationBadgeColor(): ?string
{
return 'success'; // Verde para productos
}
}
@@ -0,0 +1,181 @@
<?php
namespace App\Filament\Resources\ProductoResource\Pages;
use App\Filament\Resources\ProductoResource;
use App\Models\Producto;
use App\Models\Bodega;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
class CreateProducto extends CreateRecord
{
protected static string $resource = ProductoResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
Log::info('=== CreateProducto DEBUG ===');
Log::info('Datos completos recibidos:', $data);
Log::info('Keys disponibles:', array_keys($data));
// Verificar presencia de campos específicos
Log::info('Campos de stock presentes:', [
'stock_entrada' => isset($data['stock_entrada']) ? $data['stock_entrada'] : 'NO PRESENTE',
'stock_minimo_entrada' => isset($data['stock_minimo_entrada']) ? $data['stock_minimo_entrada'] : 'NO PRESENTE',
'stock_maximo_entrada' => isset($data['stock_maximo_entrada']) ? $data['stock_maximo_entrada'] : 'NO PRESENTE',
]);
// Asegurar que descripcion no esté vacía
if (empty($data['descripcion']) || is_null($data['descripcion'])) {
$data['descripcion'] = 'Sin descripción';
}
// Procesar stock inicial
if (isset($data['stock_entrada'])) {
$stockEntrada = $data['stock_entrada'];
$unidadMedida = $data['unidad_medida'] ?? 'unidad';
// Convertir a unidades
$stockEntradaFloat = is_numeric($stockEntrada) ? (float) $stockEntrada : 0.0;
$data['stock'] = Producto::convertirAUnidades($stockEntradaFloat, $unidadMedida);
Log::info('Stock procesado:', [
'stock_entrada' => $stockEntrada,
'unidad_medida' => $unidadMedida,
'stock_final' => $data['stock']
]);
}
// Procesar stock mínimo
if (isset($data['stock_minimo_entrada'])) {
$stockMinimoEntrada = $data['stock_minimo_entrada'];
$unidadMedida = $data['unidad_medida'] ?? 'unidad';
// Convertir a unidades
$stockMinimoEntradaFloat = is_numeric($stockMinimoEntrada) ? (float) $stockMinimoEntrada : 0.0;
$data['stock_minimo'] = Producto::convertirAUnidades($stockMinimoEntradaFloat, $unidadMedida);
Log::info('Stock mínimo procesado:', [
'stock_minimo_entrada' => $stockMinimoEntrada,
'unidad_medida' => $unidadMedida,
'stock_minimo_final' => $data['stock_minimo']
]);
}
// Procesar stock máximo
if (isset($data['stock_maximo_entrada'])) {
$stockMaximoEntrada = $data['stock_maximo_entrada'];
$unidadMedida = $data['unidad_medida'] ?? 'unidad';
// Convertir a unidades
$stockMaximoEntradaFloat = is_numeric($stockMaximoEntrada) ? (float) $stockMaximoEntrada : 0.0;
$data['stock_maximo'] = Producto::convertirAUnidades($stockMaximoEntradaFloat, $unidadMedida);
Log::info('Stock máximo procesado:', [
'stock_maximo_entrada' => $stockMaximoEntrada,
'unidad_medida' => $unidadMedida,
'stock_maximo_final' => $data['stock_maximo']
]);
}
// Remover campos auxiliares que no deben guardarse
unset($data['stock_entrada'], $data['stock_minimo_entrada'], $data['stock_maximo_entrada']);
Log::info('Datos finales para crear producto:', $data);
return $data;
}
protected function afterCreate(): void
{
$producto = $this->record;
// Solo asignar stock a bodega principal si NO tiene variantes
if ($producto->stock > 0 && !$producto->variants()->exists()) {
try {
DB::beginTransaction();
// Buscar o crear la bodega principal
$bodegaPrincipal = Bodega::firstOrCreate([
'nombre' => 'Principal'
]);
Log::info("Asignando stock a bodega principal", [
'producto_id' => $producto->id,
'producto_nombre' => $producto->nombre,
'stock' => $producto->stock,
'bodega_id' => $bodegaPrincipal->id,
'bodega_nombre' => $bodegaPrincipal->nombre
]);
// Verificar si ya existe una relación con esta bodega
$existeRelacion = $producto->bodegas()->where('bodega_id', $bodegaPrincipal->id)->exists();
if (!$existeRelacion) {
// Asignar el stock a la bodega principal
$producto->bodegas()->attach($bodegaPrincipal->id, [
'stock' => $producto->stock
]);
Log::info("Stock asignado exitosamente a bodega principal");
// Mostrar notificación al usuario
Notification::make()
->success()
->title('Producto creado exitosamente')
->body("Stock inicial de {$producto->stock} unidades asignado a la bodega Principal")
->send();
} else {
Log::info("El producto ya tiene relación con la bodega principal, actualizando stock");
// Actualizar el stock en la bodega principal
$producto->bodegas()->updateExistingPivot($bodegaPrincipal->id, [
'stock' => $producto->stock
]);
Notification::make()
->success()
->title('Producto creado exitosamente')
->body("Stock actualizado en la bodega Principal: {$producto->stock} unidades")
->send();
}
DB::commit();
} catch (\Exception $e) {
DB::rollback();
Log::error("Error al asignar stock a bodega principal", [
'producto_id' => $producto->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
// Mostrar notificación de error
Notification::make()
->warning()
->title('Producto creado con advertencia')
->body('El producto se creó correctamente, pero hubo un problema al asignar el stock a la bodega principal.')
->persistent()
->send();
}
} elseif ($producto->variants()->exists()) {
// Si tiene variantes, limpiar el stock del producto principal
$producto->update(['stock' => 0]);
Log::info("Producto con variantes creado - stock principal establecido en 0", [
'producto_id' => $producto->id,
'producto_nombre' => $producto->nombre
]);
Notification::make()
->info()
->title('Producto con variantes creado')
->body('El stock se manejará a nivel de cada variante individual')
->send();
}
}
}
@@ -0,0 +1,128 @@
<?php
namespace App\Filament\Resources\ProductoResource\Pages;
use App\Filament\Resources\ProductoResource;
use App\Models\Producto;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Log;
class EditProducto extends EditRecord
{
protected static string $resource = ProductoResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
protected function mutateFormDataBeforeFill(array $data): array
{
Log::info('Cargando datos para edición:', $data);
// Convertir valores base a campos auxiliares para mostrar en el formulario
$unidadMedida = $data['unidad_medida'] ?? 'unidad';
// Convertir stock base a campo auxiliar
if (isset($data['stock'])) {
$data['stock_entrada'] = Producto::convertirDesdeUnidades($data['stock'], $unidadMedida);
Log::info('Stock convertido para edición:', [
'stock_base' => $data['stock'],
'stock_entrada' => $data['stock_entrada'],
'unidad_medida' => $unidadMedida
]);
}
// Convertir stock mínimo base a campo auxiliar
if (isset($data['stock_minimo'])) {
$data['stock_minimo_entrada'] = Producto::convertirDesdeUnidades($data['stock_minimo'], $unidadMedida);
Log::info('Stock mínimo convertido para edición:', [
'stock_minimo_base' => $data['stock_minimo'],
'stock_minimo_entrada' => $data['stock_minimo_entrada'],
'unidad_medida' => $unidadMedida
]);
}
// Convertir stock máximo base a campo auxiliar
if (isset($data['stock_maximo'])) {
$data['stock_maximo_entrada'] = Producto::convertirDesdeUnidades($data['stock_maximo'], $unidadMedida);
Log::info('Stock máximo convertido para edición:', [
'stock_maximo_base' => $data['stock_maximo'],
'stock_maximo_entrada' => $data['stock_maximo_entrada'],
'unidad_medida' => $unidadMedida
]);
}
Log::info('Datos finales para formulario de edición:', $data);
return $data;
}
protected function mutateFormDataBeforeSave(array $data): array
{
Log::info('Datos recibidos en EditProducto:', $data);
// Asegurar que descripcion no esté vacía
if (empty($data['descripcion']) || is_null($data['descripcion'])) {
$data['descripcion'] = 'Sin descripción';
}
// Procesar stock inicial
if (isset($data['stock_entrada'])) {
$stockEntrada = $data['stock_entrada'];
$unidadMedida = $data['unidad_medida'] ?? 'unidad';
// Convertir a unidades
$stockEntradaFloat = is_numeric($stockEntrada) ? (float) $stockEntrada : 0.0;
$data['stock'] = Producto::convertirAUnidades($stockEntradaFloat, $unidadMedida);
Log::info('Stock procesado:', [
'stock_entrada' => $stockEntrada,
'unidad_medida' => $unidadMedida,
'stock_final' => $data['stock']
]);
}
// Procesar stock mínimo
if (isset($data['stock_minimo_entrada'])) {
$stockMinimoEntrada = $data['stock_minimo_entrada'];
$unidadMedida = $data['unidad_medida'] ?? 'unidad';
// Convertir a unidades
$stockMinimoEntradaFloat = is_numeric($stockMinimoEntrada) ? (float) $stockMinimoEntrada : 0.0;
$data['stock_minimo'] = Producto::convertirAUnidades($stockMinimoEntradaFloat, $unidadMedida);
Log::info('Stock mínimo procesado:', [
'stock_minimo_entrada' => $stockMinimoEntrada,
'unidad_medida' => $unidadMedida,
'stock_minimo_final' => $data['stock_minimo']
]);
}
// Procesar stock máximo
if (isset($data['stock_maximo_entrada'])) {
$stockMaximoEntrada = $data['stock_maximo_entrada'];
$unidadMedida = $data['unidad_medida'] ?? 'unidad';
// Convertir a unidades
$stockMaximoEntradaFloat = is_numeric($stockMaximoEntrada) ? (float) $stockMaximoEntrada : 0.0;
$data['stock_maximo'] = Producto::convertirAUnidades($stockMaximoEntradaFloat, $unidadMedida);
Log::info('Stock máximo procesado:', [
'stock_maximo_entrada' => $stockMaximoEntrada,
'unidad_medida' => $unidadMedida,
'stock_maximo_final' => $data['stock_maximo']
]);
}
// Remover campos auxiliares que no deben guardarse
unset($data['stock_entrada'], $data['stock_minimo_entrada'], $data['stock_maximo_entrada']);
Log::info('Datos finales para editar producto:', $data);
return $data;
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ProductoResource\Pages;
use App\Filament\Resources\ProductoResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListProductos extends ListRecords
{
protected static string $resource = ProductoResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,308 @@
<?php
namespace App\Filament\Resources\ProductoResource\RelationManagers;
use App\Models\Bodega;
use App\Models\Producto;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Notifications\Notification;
class BodegasRelationManager extends RelationManager
{
protected static string $relationship = 'bodegas';
protected static ?string $title = 'Stock por Bodega';
protected static ?string $modelLabel = 'Bodega';
protected static ?string $pluralModelLabel = 'Bodegas';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Select::make('bodega_id')
->label('Bodega')
->options(\App\Models\Bodega::pluck('nombre', 'id'))
->required()
->searchable()
->preload(),
Forms\Components\TextInput::make('stock')
->label('Stock en esta Bodega')
->numeric()
->required()
->default(0)
->minValue(0)
->helperText('Cantidad de este producto en la bodega seleccionada'),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('nombre')
->columns([
Tables\Columns\TextColumn::make('nombre')
->label('Bodega')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('pivot.stock')
->label('Stock')
->alignCenter()
->sortable()
->formatStateUsing(function ($state, $record) {
$producto = $this->getOwnerRecord();
// Validación defensiva
if (!$producto || !$producto->getKey()) {
return "Error: Producto no encontrado";
}
$unidadMedida = $producto->unidad_medida ?? 'unidad';
$stockEnUnidad = Producto::convertirDesdeUnidades($state, $unidadMedida);
$nombreUnidad = explode(' ', Producto::getUnidadesMedida()[$unidadMedida])[0];
$estado = $state > 0 ? '✅' : '⚠️';
return "{$estado} {$stockEnUnidad} {$nombreUnidad}";
})
->tooltip(function ($state, $record) {
$producto = $this->getOwnerRecord();
// Validación defensiva
if (!$producto || !$producto->getKey()) {
return "Error: No se pudo cargar información del producto";
}
$unidadMedida = $producto->unidad_medida ?? 'unidad';
$stockEnUnidad = Producto::convertirDesdeUnidades($state, $unidadMedida);
$nombreUnidad = Producto::getUnidadesMedida()[$unidadMedida];
return "Stock en {$nombreUnidad}: {$stockEnUnidad}\nStock en unidades: {$state}";
}),
Tables\Columns\TextColumn::make('pivot.updated_at')
->label('Última Actualización')
->dateTime('d/m/Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\Filter::make('con_stock')
->label('Solo con Stock')
->query(fn ($query) => $query->where('producto_bodega.stock', '>', 0)),
])
->headerActions([
Tables\Actions\CreateAction::make()
->label('Asignar a Bodega')
->modalHeading('Asignar Producto a Bodega')
->mutateFormDataUsing(function (array $data): array {
// Asegurarse de que el bodega_id esté en el pivot
return $data;
})
->using(function (array $data): void {
$producto = $this->getOwnerRecord();
// Validación defensiva para evitar error getKey() on null
if (!$producto || !$producto->getKey()) {
Notification::make()
->danger()
->title('Error')
->body('No se pudo obtener la información del producto.')
->send();
return;
}
$bodegaId = $data['bodega_id'];
$stock = $data['stock'];
// Verificar si ya existe la relación
if ($producto->bodegas()->where('bodega_id', $bodegaId)->exists()) {
Notification::make()
->warning()
->title('Producto ya asignado')
->body('Este producto ya está asignado a esa bodega. Use la opción de editar.')
->send();
return;
}
// Crear la relación
$producto->bodegas()->attach($bodegaId, ['stock' => $stock]);
Notification::make()
->success()
->title('Producto asignado')
->body('El producto ha sido asignado a la bodega correctamente.')
->send();
}),
Tables\Actions\Action::make('distribuir_stock')
->label('Distribuir Stock Total')
->icon('heroicon-o-arrows-right-left')
->color('warning')
->form([
Forms\Components\Placeholder::make('info')
->label('Información')
->content(function () {
$producto = $this->getOwnerRecord();
// Validación defensiva
if (!$producto || !$producto->getKey()) {
return "Error: No se pudo cargar información del producto";
}
$stockTotal = $producto->getStockEfectivo();
$unidadMedida = $producto->unidad_medida ?? 'unidad';
$stockEnUnidad = Producto::convertirDesdeUnidades($stockTotal, $unidadMedida);
$nombreUnidad = Producto::getUnidadesMedida()[$unidadMedida];
return "Stock total disponible: {$stockEnUnidad} {$nombreUnidad} ({$stockTotal} unidades)";
}),
Forms\Components\Repeater::make('distribucion')
->label('Distribución por Bodega')
->schema([
Forms\Components\Select::make('bodega_id')
->label('Bodega')
->options(Bodega::pluck('nombre', 'id'))
->required(),
Forms\Components\TextInput::make('stock')
->label('Stock a Asignar')
->numeric()
->required()
->default(0)
->minValue(0),
])
->minItems(1)
->addActionLabel('Añadir Bodega'),
])
->action(function (array $data): void {
$producto = $this->getOwnerRecord();
// Validación defensiva
if (!$producto || !$producto->getKey()) {
Notification::make()
->danger()
->title('Error')
->body('No se pudo obtener la información del producto.')
->send();
return;
}
$distribucion = $data['distribucion'] ?? [];
// Validar que la suma no exceda el stock total
$stockAsignado = array_sum(array_column($distribucion, 'stock'));
$stockTotal = $producto->getStockEfectivo();
if ($stockAsignado > $stockTotal) {
Notification::make()
->danger()
->title('Error de Distribución')
->body("El stock asignado ({$stockAsignado}) excede el stock disponible ({$stockTotal})")
->send();
return;
}
// Distribuir el stock
foreach ($distribucion as $item) {
$producto->bodegas()->syncWithoutDetaching([
$item['bodega_id'] => ['stock' => $item['stock']]
]);
}
Notification::make()
->success()
->title('Stock Distribuido')
->body('El stock ha sido distribuido correctamente entre las bodegas.')
->send();
}),
])
->actions([
Tables\Actions\EditAction::make()
->label('Editar Stock')
->form([
Forms\Components\TextInput::make('stock')
->label('Stock en esta Bodega')
->numeric()
->required()
->minValue(0)
->helperText('Cantidad de este producto en la bodega'),
])
->fillForm(function ($record): array {
return [
'stock' => $record->pivot?->stock ?? 0,
];
})
->using(function (array $data, $record): void {
$record->pivot->update(['stock' => (int)$data['stock']]);
Notification::make()
->success()
->title('Stock Actualizado')
->body("El stock se actualizó a {$data['stock']} unidades.")
->send();
}),
Tables\Actions\Action::make('vaciar_stock')
->label('Vaciar Stock')
->icon('heroicon-o-minus-circle')
->color('warning')
->requiresConfirmation()
->modalHeading('Vaciar Stock de Bodega')
->modalDescription('¿Estás seguro de que quieres poner el stock en 0 para esta bodega? La bodega seguirá asignada al producto.')
->modalSubmitActionLabel('Sí, vaciar stock')
->action(function ($record): void {
$record->pivot->update(['stock' => 0]);
Notification::make()
->success()
->title('Stock Vaciado')
->body('El stock de la bodega ha sido puesto en 0.')
->send();
}),
/*
Tables\Actions\DeleteAction::make()
->label('Quitar Bodega')
->requiresConfirmation()
->modalHeading('Quitar Producto de Bodega')
->modalDescription('¿Estás seguro de que quieres quitar completamente este producto de la bodega? Se perderá toda la información de stock.')
->modalSubmitActionLabel('Sí, quitar completamente'), */
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('vaciar_stock_multiple')
->label('Vaciar Stock de Seleccionadas')
->icon('heroicon-o-minus-circle')
->color('warning')
->requiresConfirmation()
->modalHeading('Vaciar Stock de Bodegas Seleccionadas')
->modalDescription('¿Estás seguro de que quieres poner el stock en 0 para todas las bodegas seleccionadas?')
->action(function ($records): void {
foreach ($records as $record) {
$record->pivot->update(['stock' => 0]);
}
Notification::make()
->success()
->title('Stock Vaciado')
->body('El stock de las bodegas seleccionadas ha sido puesto en 0.')
->send();
}),
Tables\Actions\DeleteBulkAction::make()
->label('Quitar de Bodegas Seleccionadas')
->requiresConfirmation()
->modalHeading('Quitar Producto de Bodegas')
->modalDescription('¿Estás seguro de que quieres quitar completamente este producto de las bodegas seleccionadas? Se perderá toda la información de stock.')
->modalSubmitActionLabel('Sí, quitar completamente'),
]),
]);
}
}
@@ -0,0 +1,411 @@
<?php
namespace App\Filament\Resources\ProductoResource\RelationManagers;
use App\Models\ProductVariant;
use App\Models\Bodega;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Actions\Action;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
use Filament\Notifications\Notification;
class VariantesRelationManager extends RelationManager
{
protected static string $relationship = 'variants';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Información de la Variante')
->schema([
Forms\Components\Select::make('color_id')
->relationship('color', 'name')
->searchable()
->required()
->reactive()
->afterStateUpdated(fn($set, $get, $component) => $this->updateSkuAndBarcode($set, $get, $component)),
Forms\Components\Select::make('size_id')
->relationship('size', 'name')
->searchable()
->required()
->reactive()
->afterStateUpdated(fn($set, $get, $component) => $this->updateSkuAndBarcode($set, $get, $component)),
Forms\Components\TextInput::make('stock')
->label('Stock Inicial')
->required()
->numeric()
->default(0)
->helperText('Este será el stock inicial que se asignará a la bodega seleccionada'),
Forms\Components\TextInput::make('sku')
->label('SKU')
->required()
->maxLength(50)
->dehydrated(), // Se guarda en la base de datos
TextInput::make('barcode')
->label('Código de Barras')
->required()
->length(13)
->dehydrated()
->suffixAction(
Action::make('imprimirQr')
->icon('heroicon-o-printer')
->color('primary')
->hidden(fn($record) => is_null($record))
->action(fn($state) => redirect()->route('imprimir.barcode', ['barcode' => $state]))
->hidden(fn($record) => is_null($record))
)
])
->columns(2),
Forms\Components\Section::make('Asignación de Bodega')
->schema([
Forms\Components\Select::make('bodega_id')
->label('Bodega Inicial')
->options(Bodega::pluck('nombre', 'id'))
->required()
->searchable()
->preload()
->default(function () {
// Intentar obtener la bodega principal como default
$bodegaPrincipal = Bodega::where('nombre', 'Principal')->first();
return $bodegaPrincipal?->id;
})
->helperText('Seleccione la bodega donde se asignará el stock inicial de esta variante'),
Forms\Components\Placeholder::make('info_bodega')
->label('Información')
->content('La variante se creará con el stock especificado en la bodega seleccionada. Podrá distribuir a otras bodegas posteriormente.')
])
->columns(1),
]);
}
protected static function updateSkuAndBarcode($set, $get, $component)
{
$producto = $component->getLivewire()->ownerRecord;
$color = $get('color_id') ? \App\Models\Color::find($get('color_id')) : null;
$size = $get('size_id') ? \App\Models\Size::find($get('size_id')) : null;
$exists = ProductVariant::where('producto_id', $get('producto_id'))
->where('color_id', $get('color_id'))
->where('size_id', $get('size_id'))
->exists();
if ($exists) {
// Mostrar notificación en Filament
Notification::make()
->title('Error')
->body('Esta combinación de producto, color y talla ya existe.')
->danger()
->send();
}
// Generar SKU basado en el producto, color y talla
$baseSku = strtoupper(substr($producto?->nombre ?? 'XX', 0, 2)) . '-' .
strtoupper(substr($color?->name ?? 'XX', 0, 2)) . '-' .
strtoupper(substr($size?->name ?? 'XX', 0, 2));
// Asegurar SKU único
$sku = $baseSku;
$counter = 1;
while (ProductVariant::where('sku', $sku)->exists()) {
$sku = $baseSku . '-' . $counter;
$counter++;
}
// Obtener datos para el código de barras
$countryCode = '57'; // Código de país (puedes cambiarlo)
$categoryId = $producto?->categoria_id ?? 00;
$productId = $producto?->id ?? 00000;
$variantId = ProductVariant::where('producto_id',$productId)->count() ?? 0;
$variantId += 1;
// Generar código de barras estructurado
$eanService = app(\App\Services\EAN13Service::class);
$barcode = $eanService->generateUniqueBarcode($countryCode, $categoryId, $productId, $variantId);
// Asignar valores al formulario
$set('sku', $sku);
$set('barcode', $barcode);
}
/**
* Generar SKU y código de barras únicos para una variante
*/
private function generateUniqueSkuAndBarcode(ProductVariant $variante): void
{
$producto = $variante->producto;
$color = $variante->color;
$size = $variante->size;
// Generar SKU
$baseSku = strtoupper(substr($producto->nombre ?? 'XX', 0, 2)) . '-' .
strtoupper(substr($color->name ?? 'XX', 0, 2)) . '-' .
strtoupper(substr($size->name ?? 'XX', 0, 2));
$sku = $baseSku;
$counter = 1;
while (ProductVariant::where('sku', $sku)->exists()) {
$sku = $baseSku . '-' . $counter;
$counter++;
}
// Generar código de barras
$countryCode = '57';
$categoryId = $producto->categoria_id ?? 00;
$productId = $producto->id ?? 00000;
$variantId = ProductVariant::where('producto_id', $producto->id)->count() + 1;
$eanService = app(\App\Services\EAN13Service::class);
$barcode = $eanService->generateUniqueBarcode($countryCode, $categoryId, $productId, $variantId);
$variante->sku = $sku;
$variante->barcode = $barcode;
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('sku')
->columns([
Tables\Columns\TextColumn::make('sku')
->label('SKU')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('color.name')
->label('Color')
->badge()
->color('info'),
Tables\Columns\TextColumn::make('size.name')
->label('Talla')
->badge()
->color('warning'),
Tables\Columns\TextColumn::make('stock')
->label('Stock Directo')
->numeric()
->sortable()
->tooltip('Stock directo de la variante (sin considerar bodegas)'),
Tables\Columns\TextColumn::make('stock_efectivo')
->label('Stock Total')
->getStateUsing(function ($record) {
return $record->getStockEfectivo();
})
->badge()
->color(fn ($state) => $state > 0 ? 'success' : 'danger')
->tooltip('Stock total considerando todas las bodegas'),
Tables\Columns\TextColumn::make('bodegas_info')
->label('Distribución en Bodegas')
->getStateUsing(function ($record) {
$bodegas = $record->bodegas()->get();
if ($bodegas->count() === 0) {
return 'Sin asignar a bodegas';
}
$distribucion = $bodegas->map(function ($bodega) {
return "{$bodega->nombre}: {$bodega->pivot->stock}";
})->join(' | ');
return $distribucion;
})
->wrap()
->tooltip('Distribución de stock por bodega'),
Tables\Columns\TextColumn::make('barcode')
->label('Código de Barras')
->action(fn($record) => redirect()->route('imprimir.barcode', ['barcode' => $record->barcode]))
->tooltip('Click para imprimir código QR')
->copyable()
->copyMessage('Código copiado')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('color_id')
->label('Color')
->relationship('color', 'name')
->searchable(),
Tables\Filters\SelectFilter::make('size_id')
->label('Talla')
->relationship('size', 'name')
->searchable(),
Tables\Filters\Filter::make('con_stock')
->label('Con Stock')
->query(fn (Builder $query): Builder =>
$query->where('stock', '>', 0)
->orWhereHas('bodegas', function ($q) {
$q->where('variante_bodega.stock', '>', 0);
})
),
Tables\Filters\Filter::make('sin_stock')
->label('Sin Stock')
->query(fn (Builder $query): Builder =>
$query->where('stock', '=', 0)
->whereDoesntHave('bodegas', function ($q) {
$q->where('variante_bodega.stock', '>', 0);
})
),
Tables\Filters\SelectFilter::make('bodega_id')
->label('En Bodega')
->options(Bodega::pluck('nombre', 'id'))
->query(function (Builder $query, array $data): Builder {
return $query->when(
$data['value'],
fn (Builder $query, $value): Builder => $query->whereHas('bodegas', function ($q) use ($value) {
$q->where('bodegas.id', $value);
})
);
}),
])
->headerActions([
Tables\Actions\CreateAction::make()
->label('Crear Variante')
->modalHeading('Crear Nueva Variante')
->modalDescription('Complete la información de la variante y seleccione la bodega inicial')
->modalWidth('3xl')
->using(function (array $data, string $model): ProductVariant {
// Extraer bodega_id de los datos antes de crear la variante
$bodegaId = $data['bodega_id'];
$stockInicial = $data['stock'];
// Remover bodega_id de los datos para evitar errores en la creación
unset($data['bodega_id']);
// Crear la variante
$variante = $this->getOwnerRecord()->variants()->create($data);
// Asignar a la bodega seleccionada
if ($bodegaId && $stockInicial > 0) {
$variante->bodegas()->attach($bodegaId, ['stock' => $stockInicial]);
$bodegaNombre = Bodega::find($bodegaId)->nombre;
Notification::make()
->success()
->title('Variante creada exitosamente')
->body("La variante {$variante->sku} se creó con {$stockInicial} unidades en la bodega {$bodegaNombre}")
->send();
} else if ($bodegaId) {
// Si no hay stock inicial pero se seleccionó bodega, crear la relación con stock 0
$variante->bodegas()->attach($bodegaId, ['stock' => 0]);
$bodegaNombre = Bodega::find($bodegaId)->nombre;
Notification::make()
->success()
->title('Variante creada')
->body("La variante {$variante->sku} se creó y asignó a la bodega {$bodegaNombre}")
->send();
}
return $variante;
}),
])
->actions([
Tables\Actions\EditAction::make()
->label('Editar')
->modalWidth('3xl'),
Tables\Actions\Action::make('gestionar_bodegas')
->label('Gestionar Bodegas')
->icon('heroicon-o-building-storefront')
->color('info')
->url(fn (ProductVariant $record): string =>
"/admin/product-variants/{$record->id}/edit"
)
->openUrlInNewTab()
->tooltip('Abrir gestión completa de bodegas para esta variante'),
Tables\Actions\Action::make('duplicar_variante')
->label('Duplicar')
->icon('heroicon-o-document-duplicate')
->color('warning')
->form([
Forms\Components\Select::make('color_id')
->label('Nuevo Color')
->relationship('color', 'name')
->required(),
Forms\Components\Select::make('size_id')
->label('Nueva Talla')
->relationship('size', 'name')
->required(),
Forms\Components\TextInput::make('stock_inicial')
->label('Stock Inicial')
->numeric()
->default(0),
Forms\Components\Select::make('bodega_id')
->label('Bodega Inicial')
->options(Bodega::pluck('nombre', 'id'))
->required(),
])
->action(function (ProductVariant $record, array $data): void {
// Verificar que no exista la combinación
$exists = ProductVariant::where('producto_id', $record->producto_id)
->where('color_id', $data['color_id'])
->where('size_id', $data['size_id'])
->exists();
if ($exists) {
Notification::make()
->danger()
->title('Error')
->body('Ya existe una variante con esa combinación de color y talla')
->send();
return;
}
// Crear nueva variante
$newVariant = $record->replicate([
'sku', 'barcode', 'color_id', 'size_id'
]);
$newVariant->color_id = $data['color_id'];
$newVariant->size_id = $data['size_id'];
$newVariant->stock = $data['stock_inicial'];
// Generar SKU y código de barras únicos
$this->generateUniqueSkuAndBarcode($newVariant);
$newVariant->save();
// Asignar a bodega
if ($data['bodega_id'] && $data['stock_inicial'] > 0) {
$newVariant->bodegas()->attach($data['bodega_id'], ['stock' => $data['stock_inicial']]);
}
Notification::make()
->success()
->title('Variante duplicada')
->body("Nueva variante {$newVariant->sku} creada exitosamente")
->send();
}),
Tables\Actions\DeleteAction::make()
->label('Eliminar'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ProveedorResource\Pages;
use App\Filament\Resources\ProveedorResource\RelationManagers;
use App\Models\Proveedor;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class ProveedorResource extends Resource
{
protected static ?string $model = Proveedor::class;
protected static ?string $navigationGroup = 'Administración'; //
protected static ?string $navigationIcon = 'heroicon-o-users';
public static function canViewAny(): bool
{
return auth()->user()->can('ver proveedores');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('nombre')
->required()
->maxLength(255),
Forms\Components\TextInput::make('contacto')
->maxLength(255),
Forms\Components\TextInput::make('telefono')
->tel()
->maxLength(20),
Forms\Components\TextInput::make('correo')
->maxLength(255),
Forms\Components\Textarea::make('direccion')
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('nombre')
->searchable(),
Tables\Columns\TextColumn::make('contacto')
->searchable(),
Tables\Columns\TextColumn::make('telefono')
->searchable(),
Tables\Columns\TextColumn::make('correo')
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListProveedors::route('/'),
'create' => Pages\CreateProveedor::route('/create'),
'edit' => Pages\EditProveedor::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\ProveedorResource\Pages;
use App\Filament\Resources\ProveedorResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateProveedor extends CreateRecord
{
protected static string $resource = ProveedorResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ProveedorResource\Pages;
use App\Filament\Resources\ProveedorResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditProveedor extends EditRecord
{
protected static string $resource = ProveedorResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ProveedorResource\Pages;
use App\Filament\Resources\ProveedorResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListProveedors extends ListRecords
{
protected static string $resource = ProveedorResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Filament\Resources\ReporteventasResource\Pages;
use Filament\Pages\Page; // ✅ CORRECTO
use Illuminate\Support\Facades\Mail;
use App\Mail\ReporteVentasMail;
use App\Models\Venta;
class ReporteVentas extends Page
{
protected static string $view = 'filament.pages.reporte-ventas';
protected static ?string $navigationGroup = 'Reportes'; //
protected static ?string $navigationIcon = 'heroicon-o-cube';
public static function canViewAny(): bool
{
return auth()->user()->can('ver reportes');
}
public ?string $fromDate = null;
public ?string $toDate = null;
public ?string $email = null;
public function generateReport()
{
$this->validate([
'fromDate' => 'required|date',
'toDate' => 'required|date|after_or_equal:fromDate',
'email' => 'required|email', // Validar que el campo 'email' sea una dirección de correo válida
]);
$email = $this->email; // El correo electrónico del formulario
// Obtener las ventas dentro del rango de fechas
$ventas = Venta::with(['cliente', 'detalles.producto', 'detalles.variante'])
->whereBetween('created_at', [$this->fromDate, $this->toDate])
->get();
// Enviar el correo con el reporte de ventas
Mail::to($email)->send(new ReporteVentasMail($ventas, $this->fromDate, $this->toDate, $email));
// Restablecer los campos a null después de enviar el correo
$this->fromDate = null;
$this->toDate = null;
$this->email = null;
// Mostrar mensaje de éxito
session()->flash('success', 'Reporte enviado al correo.');
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RoleResource\Pages;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static ?string $navigationGroup = 'Administración';
protected static ?string $navigationLabel = 'Roles';
protected static ?string $title = 'Administración de Roles';
protected static ?string $navigationIcon = 'heroicon-o-key';
public static function canViewAny(): bool
{
return auth()->user()->can('ver roles');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('Nombre del Rol')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Forms\Components\MultiSelect::make('permissions')
->label('Permisos')
->relationship('permissions', 'name')
->options(Permission::pluck('name', 'id'))
->searchable()
->preload()
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('name')
->label('Rol')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('permissions.name')
->label('Permisos')
->badge()
->separator(', '),
Tables\Columns\TextColumn::make('created_at')
->label('Creado el')
->dateTime('d/m/Y H:i')
->sortable(),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make()->visible(fn($record) => $record->name !== 'Super Admin'),
Tables\Actions\DeleteAction::make()->visible(fn($record) => $record->name !== 'Super Admin'),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make(),
]);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListRoles::route('/'),
'create' => Pages\CreateRole::route('/create'),
'edit' => Pages\EditRole::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateRole extends CreateRecord
{
protected static string $resource = RoleResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditRole extends EditRecord
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\SizeResource\Pages;
use App\Filament\Resources\SizeResource\RelationManagers;
use App\Models\Size;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class SizeResource extends Resource
{
protected static ?string $navigationGroup = 'Inventario'; //
protected static ?string $model = Size::class;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
public static function canViewAny(): bool
{
return auth()->user()->can('ver sizes');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(10),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListSizes::route('/'),
'create' => Pages\CreateSize::route('/create'),
'edit' => Pages\EditSize::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\SizeResource\Pages;
use App\Filament\Resources\SizeResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateSize extends CreateRecord
{
protected static string $resource = SizeResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\SizeResource\Pages;
use App\Filament\Resources\SizeResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditSize extends EditRecord
{
protected static string $resource = SizeResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\SizeResource\Pages;
use App\Filament\Resources\SizeResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListSizes extends ListRecords
{
protected static string $resource = SizeResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,363 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TransferenciaBodegaResource\Pages;
use App\Models\TransferenciaBodega;
use App\Models\Producto;
use App\Models\ProductVariant;
use App\Models\Bodega;
use App\Services\TransferenciaBodegaService;
use Illuminate\Support\Facades\Auth;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Notifications\Notification;
use Filament\Tables\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
class TransferenciaBodegaResource extends Resource
{
protected static ?string $model = TransferenciaBodega::class;
protected static ?string $navigationIcon = 'heroicon-o-arrows-right-left';
protected static ?string $navigationGroup = 'Inventario';
protected static ?string $navigationLabel = 'Transferencias de Stock';
protected static ?string $modelLabel = 'Transferencia';
protected static ?string $pluralModelLabel = 'Transferencias';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Información del Producto')
->schema([
Forms\Components\Select::make('producto_id')
->label('Producto')
->relationship('producto', 'nombre')
->searchable()
->preload()
->required()
->reactive()
->afterStateUpdated(function ($set, $get, $state) {
if ($state) {
$producto = Producto::find($state);
if ($producto && $producto->variants()->exists()) {
$set('tiene_variantes', true);
$set('variante_id', null); // Limpiar selección anterior
$set('stock_disponible_info', null);
// Mostrar información sobre las variantes
$cantidadVariantes = $producto->variants()->count();
$set('info_variantes', "Este producto tiene {$cantidadVariantes} variante(s). Debe seleccionar una variante específica para la transferencia.");
} else {
$set('tiene_variantes', false);
$set('variante_id', null);
$set('info_variantes', null);
// Mostrar stock disponible del producto
$stockTotal = $producto->getStockEfectivo();
$set('stock_disponible_info', "Stock total del producto: {$stockTotal} unidades");
}
} else {
$set('tiene_variantes', false);
$set('variante_id', null);
$set('info_variantes', null);
$set('stock_disponible_info', null);
}
}),
Forms\Components\Placeholder::make('info_variantes')
->label('Información del Producto')
->content(fn ($get) => $get('info_variantes') ?? '')
->visible(fn ($get) => $get('tiene_variantes')),
Forms\Components\Placeholder::make('stock_disponible_info')
->label('Stock Disponible')
->content(fn ($get) => $get('stock_disponible_info') ?? '')
->visible(fn ($get) => !$get('tiene_variantes') && $get('stock_disponible_info')),
Forms\Components\Select::make('variante_id')
->label('Seleccionar Variante')
->options(function ($get) {
$productoId = $get('producto_id');
if ($productoId) {
return ProductVariant::where('producto_id', $productoId)
->with(['color', 'size'])
->get()
->mapWithKeys(function ($variante) {
$colorName = $variante->color->name ?? '';
$sizeName = $variante->size->name ?? '';
$stockEfectivo = $variante->getStockEfectivo();
$displayName = "{$variante->sku} - {$colorName} {$sizeName} (Stock: {$stockEfectivo})";
return [$variante->id => $displayName];
});
}
return [];
})
->searchable()
->required(fn ($get) => $get('tiene_variantes'))
->visible(fn ($get) => $get('tiene_variantes'))
->reactive()
->afterStateUpdated(function ($set, $get, $state) {
if ($state) {
$variante = ProductVariant::find($state);
if ($variante) {
$stockTotal = $variante->getStockEfectivo();
$set('stock_variante_info', "Stock total de esta variante: {$stockTotal} unidades");
}
} else {
$set('stock_variante_info', null);
}
})
->helperText('Seleccione la variante específica que desea transferir'),
Forms\Components\Placeholder::make('stock_variante_info')
->label('Stock de la Variante')
->content(fn ($get) => $get('stock_variante_info') ?? '')
->visible(fn ($get) => $get('tiene_variantes') && $get('variante_id') && $get('stock_variante_info')),
])
->columns(2),
Forms\Components\Section::make('Detalles de la Transferencia')
->schema([
Forms\Components\Select::make('bodega_origen_id')
->label('Bodega Origen')
->options(function ($get) {
$productoId = $get('producto_id');
$varianteId = $get('variante_id');
if ($varianteId) {
// Si hay variante seleccionada, mostrar solo bodegas con stock de esa variante
$variante = ProductVariant::find($varianteId);
if ($variante) {
return $variante->bodegas()
->where('variante_bodega.stock', '>', 0)
->get()
->mapWithKeys(function ($bodega) {
return [$bodega->id => "{$bodega->nombre} (Stock: {$bodega->pivot->stock})"];
});
}
} elseif ($productoId) {
// Si hay producto sin variantes, mostrar bodegas con stock del producto
$producto = Producto::find($productoId);
if ($producto && !$producto->variants()->exists()) {
return $producto->bodegas()
->where('producto_bodega.stock', '>', 0)
->get()
->mapWithKeys(function ($bodega) {
return [$bodega->id => "{$bodega->nombre} (Stock: {$bodega->pivot->stock})"];
});
}
}
// Fallback: mostrar todas las bodegas
return Bodega::pluck('nombre', 'id');
})
->searchable()
->required()
->reactive()
->afterStateUpdated(function ($set, $get, $state) {
$set('stock_origen_disponible', null);
if ($state) {
$productoId = $get('producto_id');
$varianteId = $get('variante_id');
if ($varianteId) {
$variante = ProductVariant::find($varianteId);
$stock = $variante->bodegas()
->where('bodega_id', $state)
->first()?->pivot?->stock ?? 0;
} elseif ($productoId) {
$producto = Producto::find($productoId);
$stock = $producto->bodegas()
->where('bodega_id', $state)
->first()?->pivot?->stock ?? 0;
} else {
$stock = 0;
}
$bodegaNombre = Bodega::find($state)?->nombre ?? '';
$set('stock_origen_disponible', "Stock disponible en {$bodegaNombre}: {$stock} unidades");
$set('max_cantidad', $stock);
}
})
->helperText('Solo se muestran bodegas con stock disponible'),
Forms\Components\Placeholder::make('stock_origen_disponible')
->label('Stock en Bodega Origen')
->content(fn ($get) => $get('stock_origen_disponible') ?? '')
->visible(fn ($get) => $get('stock_origen_disponible')),
Forms\Components\Select::make('bodega_destino_id')
->label('Bodega Destino')
->relationship('bodegaDestino', 'nombre')
->searchable()
->required()
->different('bodega_origen_id'),
Forms\Components\TextInput::make('cantidad')
->label('Cantidad a Transferir')
->numeric()
->required()
->minValue(1)
->maxValue(fn ($get) => $get('max_cantidad') ?? 999999)
->helperText(fn ($get) =>
$get('max_cantidad') ? "Máximo disponible: {$get('max_cantidad')} unidades" : ''
),
Forms\Components\Textarea::make('motivo')
->label('Motivo de la Transferencia')
->placeholder('Ej: Reposición de sucursal, reorganización de inventario...')
->rows(3)
->columnSpanFull(),
])
->columns(2),
Forms\Components\Hidden::make('usuario_id')
->default(Auth::id()),
Forms\Components\Hidden::make('fecha_transferencia')
->default(now()),
Forms\Components\Hidden::make('tiene_variantes')
->default(false),
Forms\Components\Hidden::make('max_cantidad')
->default(0),
Forms\Components\Hidden::make('info_variantes'),
Forms\Components\Hidden::make('stock_disponible_info'),
Forms\Components\Hidden::make('stock_variante_info'),
Forms\Components\Hidden::make('stock_origen_disponible'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('producto.nombre')
->label('Producto')
->searchable()
->sortable()
->wrap(),
Tables\Columns\TextColumn::make('variante_info')
->label('Variante')
->getStateUsing(function ($record) {
if ($record->variante_id) {
$variante = ProductVariant::find($record->variante_id);
if ($variante) {
$colorName = $variante->color->name ?? '';
$sizeName = $variante->size->name ?? '';
return "{$variante->sku} - {$colorName} {$sizeName}";
}
}
return 'Producto general';
})
->badge()
->color(fn ($state) => $state === 'Producto general' ? 'info' : 'warning')
->searchable(query: function (Builder $query, string $search): Builder {
return $query->whereHas('producto.variants', function ($q) use ($search) {
$q->where('sku', 'like', "%{$search}%");
});
}),
Tables\Columns\TextColumn::make('bodegaOrigen.nombre')
->label('Bodega Origen')
->badge()
->color('danger')
->sortable(),
Tables\Columns\TextColumn::make('bodegaDestino.nombre')
->label('Bodega Destino')
->badge()
->color('success')
->sortable(),
Tables\Columns\TextColumn::make('cantidad')
->label('Cantidad')
->numeric()
->sortable()
->badge()
->color('primary'),
Tables\Columns\TextColumn::make('motivo')
->label('Motivo')
->limit(30)
->tooltip(function ($record) {
return $record->motivo;
})
->wrap(),
Tables\Columns\TextColumn::make('usuario.name')
->label('Usuario')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('fecha_transferencia')
->label('Fecha')
->dateTime('d/m/Y H:i')
->sortable()
->badge()
->color('gray'),
Tables\Columns\TextColumn::make('created_at')
->label('Creado')
->dateTime('d/m/Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('bodega_origen_id')
->label('Bodega Origen')
->relationship('bodegaOrigen', 'nombre'),
SelectFilter::make('bodega_destino_id')
->label('Bodega Destino')
->relationship('bodegaDestino', 'nombre'),
SelectFilter::make('producto_id')
->label('Producto')
->relationship('producto', 'nombre')
->searchable(),
Tables\Filters\Filter::make('con_variantes')
->label('Solo Variantes')
->query(fn (Builder $query): Builder => $query->whereNotNull('variante_id')),
Tables\Filters\Filter::make('sin_variantes')
->label('Solo Productos Generales')
->query(fn (Builder $query): Builder => $query->whereNull('variante_id')),
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTransferenciaBodegas::route('/'),
'create' => Pages\CreateTransferenciaBodega::route('/create'),
'edit' => Pages\EditTransferenciaBodega::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Resources\TransferenciaBodegaResource\Pages;
use App\Filament\Resources\TransferenciaBodegaResource;
use App\Services\TransferenciaBodegaService;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
use Filament\Notifications\Notification;
class CreateTransferenciaBodega extends CreateRecord
{
protected static string $resource = TransferenciaBodegaResource::class;
protected function handleRecordCreation(array $data): \Illuminate\Database\Eloquent\Model
{
$transferenciaBodegaService = app(TransferenciaBodegaService::class);
try {
if (!empty($data['variante_id'])) {
// Transferencia de variante
$transferencia = $transferenciaBodegaService->transferirVariante(
$data['variante_id'],
$data['bodega_origen_id'],
$data['bodega_destino_id'],
$data['cantidad'],
$data['motivo'] ?? null
);
} else {
// Transferencia de producto
$transferencia = $transferenciaBodegaService->transferir(
$data['producto_id'],
$data['bodega_origen_id'],
$data['bodega_destino_id'],
$data['cantidad'],
$data['motivo'] ?? null
);
}
Notification::make()
->title('Transferencia completada exitosamente')
->success()
->send();
return $transferencia;
} catch (\Exception $e) {
Notification::make()
->title('Error en la transferencia')
->body($e->getMessage())
->danger()
->send();
throw $e;
}
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TransferenciaBodegaResource\Pages;
use App\Filament\Resources\TransferenciaBodegaResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTransferenciaBodega extends EditRecord
{
protected static string $resource = TransferenciaBodegaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TransferenciaBodegaResource\Pages;
use App\Filament\Resources\TransferenciaBodegaResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTransferenciaBodegas extends ListRecords
{
protected static string $resource = TransferenciaBodegaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationGroup = 'Administración'; //
protected static ?string $navigationLabel = 'Usuarios';
public static function getModelLabel(): string
{
return 'Usuario'; // Nombre singular
}
public static function getPluralModelLabel(): string
{
return 'Usuarios'; // Nombre plural
}
public static function canViewAny(): bool
{
return auth()->user()->can('ver usuarios');
}
protected static ?string $navigationIcon = 'heroicon-o-user';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
->email()
->required()
->maxLength(255),
//Forms\Components\DateTimePicker::make('email_verified_at'),
Forms\Components\TextInput::make('password')
->password()
->required()
->maxLength(255),
Forms\Components\Select::make('roles')
->label('Roles')
->relationship('roles', 'name') // Relación con el modelo Role
->multiple() // Permite seleccionar varios roles
->preload() // Carga la lista al abrir el formulario
->searchable() // Permite buscar roles en la lista
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('email')
->searchable(),
Tables\Columns\TextColumn::make('roles.name') // Muestra los roles
->label('Roles')
->badge() // Muestra los roles con etiquetas visuales
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
RelationManagers\RolesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeUserMail;
use App\Models\User;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function handleRecordCreation(array $data): User
{
// Crear el usuario con la contraseña hasheada
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => $data['password'],
]);
// Asignar roles si es necesario
if (isset($data['roles'])) {
$user->roles()->sync($data['roles']);
}
$password=$data['password'];
// Enviar el correo de bienvenida
Mail::to($user->email)->send(new WelcomeUserMail($user, $password));
return $user;
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Filament\Resources\UserResource\RelationManagers;
use Filament\Forms;
use Filament\Tables;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Forms\Form;
use Filament\Tables\Table;
class RolesRelationManager extends RelationManager
{
protected static string $relationship = 'roles';
public function form(Form $form): Form
{
return $form->schema([
Forms\Components\Select::make('roles')
->relationship('roles', 'name')
->multiple()
->preload()
->searchable(),
]);
}
public function table(Table $table): Table
{
return $table->columns([
Tables\Columns\TextColumn::make('name')->sortable()->searchable(),
])
->filters([])
->headerActions([
Tables\Actions\AttachAction::make(),
])
->actions([
Tables\Actions\DetachAction::make(),
]);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\VentaResource\Pages;
use App\Filament\Resources\VentaResource\RelationManagers;
use App\Filament\Resources\VentaResource\RelationManagers\DetallesRelationManagerRelationManager;
use App\Models\Venta;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Filament\Tables\Actions\Action;
use Filament\Support\Enums\ActionSize;
class VentaResource extends Resource
{
protected static ?string $model = Venta::class;
protected static ?string $navigationGroup = 'Operación'; //
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';
public static function canViewAny(): bool
{
return auth()->user()->can('ver ventas');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Select::make('cliente_id')
->relationship('cliente', 'id'),
Forms\Components\TextInput::make('total')
->required()
->numeric(),
Forms\Components\TextInput::make('tipo_pago')
->required(),
Forms\Components\TextInput::make('estado')
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('cliente.numero_documento')
->label('Cliente')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('total')
->money('COP')
->sortable(),
Tables\Columns\TextColumn::make('tipo_pago')
->badge()
->color(fn (string $state): string => match ($state) {
'Efectivo' => 'success',
'Transferencia' => 'info',
default => 'gray',
}),
Tables\Columns\TextColumn::make('estado')
->badge()
->color(fn (string $state): string => match ($state) {
'Pagado' => 'success',
'Pendiente' => 'warning',
'Cancelado' => 'danger',
default => 'gray',
}),
Tables\Columns\TextColumn::make('bodegas_usadas')
->label('Bodegas')
->getStateUsing(function ($record) {
$bodegas = $record->detalles()
->whereNotNull('bodega_id')
->with('bodega')
->get()
->pluck('bodega.nombre')
->unique()
->filter()
->values();
if ($bodegas->isEmpty()) {
return 'Stock directo';
}
return $bodegas->join(', ');
})
->badge()
->color('info')
->tooltip('Bodegas de donde se descontó el stock'),
Tables\Columns\TextColumn::make('created_at')
->label('Fecha')
->dateTime('d/m/Y H:i')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->actions([
Tables\Actions\EditAction::make(),
Action::make('recibo')
->label('Recibo')
->icon('heroicon-o-printer')
->color('secondary')
->url(fn($record) => route('imprimir-recibo', ['venta' => $record->id]))
->openUrlInNewTab()
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
DetallesRelationManagerRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListVentas::route('/'),
'create' => Pages\CreateVenta::route('/create'),
'edit' => Pages\EditVenta::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\VentaResource\Pages;
use App\Filament\Resources\VentaResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateVenta extends CreateRecord
{
protected static string $resource = VentaResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\VentaResource\Pages;
use App\Filament\Resources\VentaResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditVenta extends EditRecord
{
protected static string $resource = VentaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\VentaResource\Pages;
use App\Filament\Resources\VentaResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListVentas extends ListRecords
{
protected static string $resource = VentaResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\VentaResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class DetallesRelationManagerRelationManager extends RelationManager
{
protected static string $relationship = 'detalles';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('detalles')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('detalles')
->columns([
Tables\Columns\TextColumn::make('producto.nombre')->label('Producto'),
Tables\Columns\TextColumn::make('variante')
->label('Variante')
->formatStateUsing(function ($record) {
if ($record->variante) {
return $record->variante->color->name . ' / ' . $record->variante->size->name;
}
return '—';
}),
Tables\Columns\TextColumn::make('cantidad')->label('Cantidad'),
Tables\Columns\TextColumn::make('precio_unitario')->label('Precio Unitario'),
Tables\Columns\TextColumn::make('subtotal')->label('Subtotal'),
])
->filters([
//
])
->headerActions([
/* Tables\Actions\CreateAction::make(), */
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

Some files were not shown because too many files have changed in this diff Show More