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 } }