up
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+56
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+275
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+269
@@ -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'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+411
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+57
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+61
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user