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
@@ -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(),
]),
]);
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Producto;
use App\Models\Bodega;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class AlertasStockBodegasWidget extends BaseWidget
{
protected static bool $isLazy = false;
protected function getStats(): array
{
try {
$stats = [];
// Productos con stock bajo general
$productosStockBajo = Producto::where('estado', true)
->get()
->filter(function ($producto) {
return $producto->getStockEfectivo() <= $producto->stock_minimo;
})
->count();
$stats[] = Stat::make('Stock Bajo Global', $productosStockBajo)
->description('Productos bajo mínimo')
->descriptionIcon('heroicon-m-exclamation-triangle')
->color($productosStockBajo > 0 ? 'danger' : 'success');
// Productos con stock alto general
$productosStockAlto = Producto::where('estado', true)
->whereNotNull('stock_maximo')
->get()
->filter(function ($producto) {
return $producto->getStockEfectivo() > $producto->stock_maximo;
})
->count();
$stats[] = Stat::make('Stock Excesivo', $productosStockAlto)
->description('Productos sobre máximo')
->descriptionIcon('heroicon-m-arrow-trending-up')
->color($productosStockAlto > 0 ? 'warning' : 'success');
// Análisis por bodega crítica (si existe)
$bodegaPrincipal = Bodega::where('nombre', 'Principal')->first();
if ($bodegaPrincipal) {
$productosConStockCero = $bodegaPrincipal->productos()
->wherePivot('stock', 0)
->count();
$stats[] = Stat::make('Sin Stock en Principal', $productosConStockCero)
->description('Productos agotados')
->descriptionIcon('heroicon-m-x-circle')
->color($productosConStockCero > 0 ? 'danger' : 'success');
}
return $stats;
} catch (\Exception $e) {
// En caso de error, devolver estadísticas básicas
return [
Stat::make('Sistema de Alertas', 'Configurando...')
->description('Ejecute las migraciones necesarias')
->descriptionIcon('heroicon-m-cog-6-tooth')
->color('warning')
];
}
}
protected function getColumns(): int
{
return 3;
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Filament\Widgets;
use App\Models\TransferenciaBodega;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Carbon\Carbon;
class TransferenciasBodegaStatsWidget extends BaseWidget
{
protected function getStats(): array
{
$hoy = Carbon::today();
$estaSemana = Carbon::now()->startOfWeek();
$esteMes = Carbon::now()->startOfMonth();
$transferenciasHoy = TransferenciaBodega::whereDate('fecha_transferencia', $hoy)->count();
$transferenciasEstaSemana = TransferenciaBodega::where('fecha_transferencia', '>=', $estaSemana)->count();
$transferenciasEsteMes = TransferenciaBodega::where('fecha_transferencia', '>=', $esteMes)->count();
return [
Stat::make('Transferencias Hoy', $transferenciasHoy)
->description('Transferencias realizadas hoy')
->descriptionIcon('heroicon-m-arrow-trending-up')
->color('success'),
Stat::make('Esta Semana', $transferenciasEstaSemana)
->description('Transferencias esta semana')
->descriptionIcon('heroicon-m-calendar-days')
->color('warning'),
Stat::make('Este Mes', $transferenciasEsteMes)
->description('Transferencias este mes')
->descriptionIcon('heroicon-m-chart-bar')
->color('primary'),
];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Filament\Widgets;
use Filament\Widgets\ChartWidget;
use App\Models\Venta;
use Carbon\Carbon;
class VentasChart extends ChartWidget
{
protected static ?string $heading = 'Reporte de Ventas';
protected function getData(): array
{
$ventas = Venta::whereBetween('created_at', [Carbon::now()->startOfMonth(), Carbon::now()->endOfMonth()])
->groupByRaw('DATE(created_at)')
->selectRaw('DATE(created_at) as dia, SUM(total) as total')
->pluck('total', 'dia');
return [
'datasets' => [
[
'label' => 'Ventas del mes',
'data' => array_values($ventas->toArray()),
],
],
'labels' => array_keys($ventas->toArray()),
];
}
protected function getType(): string
{
return 'bar';
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Hashing;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Support\Str;
class PBKDF2Hasher implements Hasher
{
protected $iterations;
protected $saltLength;
protected $algorithm;
public function __construct()
{
$this->iterations = 870000; // Debe coincidir con las iteraciones en Django
$this->saltLength = 22; // Puedes ajustar el tamaño del salt según lo que prefieras
$this->algorithm = 'sha256'; // Algoritmo utilizado (puede ser sha256, sha512, etc.)
}
public function make($value, array $options = [])
{
$salt = Str::random($this->saltLength); // Genera un salt aleatorio de 22 caracteres
$hash = hash_pbkdf2($this->algorithm, $value, $salt, $this->iterations, 64, false);
return "pbkdf2_sha256$$this->iterations$$salt$$hash";
}
public function check($value, $hashedValue, array $options = [])
{
$parts = explode('$', $hashedValue);
if (count($parts) !== 4) {
return false;
}
list(, $iterations, $salt, $hash) = $parts;
// Verificar si la contraseña coincide
$newHash = hash_pbkdf2($this->algorithm, $value, $salt, $iterations, 64, false);
return hash_equals($newHash, $hash); // Comparar hashes de manera segura
}
public function info($hashedValue)
{
$parts = explode('$', $hashedValue);
if (count($parts) !== 4) {
return [];
}
list(, $iterations, $salt, $hash) = $parts;
return [
'algorithm' => $this->algorithm,
'iterations' => (int) $iterations,
'salt' => $salt,
'hash' => $hash,
];
}
public function needsRehash($hashedValue, array $options = [])
{
// Si necesitas implementar una lógica para verificar si el hash necesita rehacerse
return false;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\CajaStoreRequest;
use App\Http\Requests\CajaUpdateRequest;
use App\Http\Resources\CajaCollection;
use App\Http\Resources\CajaResource;
use App\Models\Caja;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CajaController extends Controller
{
public function index(Request $request): CajaCollection
{
$cajas = Caja::all();
return new CajaCollection($cajas);
}
public function store(CajaStoreRequest $request): CajaResource
{
$caja = Caja::create($request->validated());
return new CajaResource($caja);
}
public function show(Request $request, Caja $caja): CajaResource
{
return new CajaResource($caja);
}
public function update(CajaUpdateRequest $request, Caja $caja): CajaResource
{
$caja->update($request->validated());
return new CajaResource($caja);
}
public function destroy(Request $request, Caja $caja): Response
{
$caja->delete();
return response()->noContent();
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\CategoriumStoreRequest;
use App\Http\Requests\CategoriumUpdateRequest;
use App\Http\Resources\CategoriumCollection;
use App\Http\Resources\CategoriumResource;
use App\Models\Categoria;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CategoriaController extends Controller
{
public function index(Request $request): CategoriumCollection
{
$categoria = Categorium::all();
return new CategoriumCollection($categoria);
}
public function store(CategoriumStoreRequest $request): CategoriumResource
{
$categorium = Categorium::create($request->validated());
return new CategoriumResource($categorium);
}
public function show(Request $request, Categorium $categorium): CategoriumResource
{
return new CategoriumResource($categorium);
}
public function update(CategoriumUpdateRequest $request, Categorium $categorium): CategoriumResource
{
$categorium->update($request->validated());
return new CategoriumResource($categorium);
}
public function destroy(Request $request, Categorium $categorium): Response
{
$categorium->delete();
return response()->noContent();
}
}

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