?php namespace App\Filament\Admin\Resources; use App\Filament\Admin\Resources\ProductResource\Pages; use App\Filament\Admin\Resources\ProductResource\RelationManagers\CategoriesRelationManager; use App\Services\SeoGeneratorService; use App\Models\Product; use App\Models\Format; use App\Models\Color; use App\Models\Occasion; use App\Models\Flower; use Filament\Forms; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Filters\SelectFilter; use Illuminate\Support\Str; use Filament\Notifications\Notification; use App\Services\ImageProcessor; use Illuminate\Support\Facades\Storage; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Actions; use Filament\Forms\Components\Actions\Action; class ProductResource extends Resource { protected static ?string $model = Product::class; protected static ?string $navigationIcon = 'heroicon-o-shopping-bag'; protected static ?string $navigationGroup = 'Управление товарами'; protected static ?int $navigationSort = 1; protected static ?string $modelLabel = 'Товар'; protected static ?string $pluralModelLabel = 'Товары'; public static function form(Form $form): Form { return $form->schema([ Forms\Components\Section::make('Основная информация') ->schema([ Forms\Components\TextInput::make('name') ->required() ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function (callable $set, $state) { $set('slug', Str::slug($state)); }) ->label('Название товара'), Forms\Components\TextInput::make('slug') ->required() ->maxLength(255) ->unique(ignoreRecord: true) ->label('URL-адрес'), Forms\Components\Textarea::make('description') ->columnSpanFull() ->label('Описание'), Forms\Components\FileUpload::make('main_image_upload') ->label('Главное изображение') ->disk('public') ->directory('tmp') ->image() ->required(fn ($livewire) => $livewire instanceof \Filament\Resources\Pages\CreateRecord) ->columnSpanFull() ->preserveFilenames() ->imageEditor() ->openable() ->downloadable() ->afterStateUpdated(function ($state, callable $set) { if (!$state) { $set('main_image_webp', null); $set('main_image', null); $set('main_image_webp_medium', null); $set('main_image_jpg_medium', null); $set('main_image_webp_small', null); $set('main_image_jpg_small', null); return; } try { $tempPath = $state->storeAs('tmp', $state->getClientOriginalName(), 'public'); $fullPath = storage_path('app/public/' . $tempPath); $paths = ImageProcessor::processFromPath($fullPath, 'products/main', true); $set('main_image_webp', $paths['webp'] ?? null); $set('main_image', $paths['jpg'] ?? null); $set('main_image_webp_medium', $paths['webp_medium'] ?? null); $set('main_image_jpg_medium', $paths['jpg_medium'] ?? null); $set('main_image_webp_small', $paths['webp_small'] ?? null); $set('main_image_jpg_small', $paths['jpg_small'] ?? null); Storage::disk('public')->delete($tempPath); } catch (\Throwable $e) { \Log::error('Ошибка при обработке изображения: ' . $e->getMessage()); Notification::make() ->title('Ошибка при обработке изображения') ->body($e->getMessage()) ->danger() ->send(); } }) ->dehydrated(false) ->dehydrateStateUsing(fn ($state) => null), Forms\Components\ViewField::make('current_main_image') ->label('Текущее изображение') ->view('filament.components.product-image-preview') ->hidden(fn ($record) => !$record?->main_image) ->columnSpanFull() ->extraAttributes([ 'x-data' => "{ confirmDelete() { if (confirm('Вы уверены, что хотите удалить главное изображение?')) { \$wire.dispatch('delete-main-image'); } } }", 'x-on:delete-main-image.window' => "confirmDelete()", ]), Forms\Components\ViewField::make('current_gallery') ->label('Текущие изображения галереи') ->view('filament.components.product-gallery-preview') ->hidden(fn ($record) => empty($record?->gallery)) ->columnSpanFull() ->extraAttributes([ 'x-data' => "{ confirmDelete(index) { if (confirm('Вы уверены, что хотите удалить это изображение из галереи?')) { \$wire.dispatch('delete-gallery-image', { index: index }); } } }", 'x-on:delete-gallery-image.window' => 'confirmDelete($event.detail.index)', ]), Hidden::make('main_image_webp'), Hidden::make('main_image'), Hidden::make('main_image_webp_medium'), Hidden::make('main_image_jpg_medium'), Hidden::make('main_image_webp_small'), Hidden::make('main_image_jpg_small'), // Новое поле для загрузки видео Forms\Components\FileUpload::make('video_file') ->label('Видеофайл (mp4, webm)') ->disk('public') ->directory('products/video') ->acceptedFileTypes(['video/mp4', 'video/webm']) ->maxSize(102400) // 100 MB ->visibility('public') ->nullable(), Forms\Components\TextInput::make('video_url') ->label('Ссылка на видео (YouTube/Vimeo)') ->url() ->nullable(), Forms\Components\Toggle::make('is_active') ->label('Активен') ->default(true), Forms\Components\Toggle::make('is_bestseller') ->label('Хит продаж') ->default(false), ]) ->columns(['md' => 2, 'lg' => 3]), Forms\Components\Section::make('Галерея изображений') ->schema([ Forms\Components\FileUpload::make('gallery_upload') ->label('Галерея изображений') ->disk('public') ->directory('tmp') ->image() ->multiple() ->reorderable() ->appendFiles() ->preserveFilenames() ->imageEditor() ->openable() ->downloadable() ->columnSpanFull() ->afterStateUpdated(function (callable $get, callable $set, $state) { $gallery = $get('gallery') ?? []; if (!$state) { $set('gallery', []); return; } $newImages = []; foreach ($state as $file) { if ($file instanceof TemporaryUploadedFile) { try { $tempPath = $file->storeAs('tmp', $file->getClientOriginalName(), 'public'); $fullPath = storage_path('app/public/' . $tempPath); $paths = ImageProcessor::processFromPath($fullPath, 'products/gallery', false); $newImages[] = [ 'webp' => $paths['webp'] ?? null, 'jpg' => $paths['jpg'] ?? null ]; Storage::disk('public')->delete($tempPath); } catch (\Throwable $e) { \Log::error('Ошибка при обработке изображения галереи: ' . $e->getMessage()); Notification::make() ->title('Ошибка при обработке изображения галереи') ->body($e->getMessage()) ->danger() ->send(); } } } $set('gallery', array_merge($gallery, $newImages)); }) ->dehydrated(false) ->dehydrateStateUsing(fn ($state) => null), Hidden::make('gallery'), ]), Forms\Components\Section::make('Цены и наличие') ->schema([ Forms\Components\TextInput::make('price') ->required() ->numeric() ->prefix('₽') ->label('Цена'), Forms\Components\TextInput::make('old_price') ->numeric() ->prefix('₽') ->label('Старая цена (если есть скидка)'), Forms\Components\Toggle::make('in_stock') ->required() ->label('В наличии') ->default(true), Forms\Components\TextInput::make('stock_count') ->numeric() ->label('Количество на складе') ->helperText('Оставьте 0 для бесконечного количества') ->default(0), ]) ->columns(['md' => 2]), Forms\Components\Section::make('Характеристики') ->schema([ Forms\Components\TextInput::make('height_cm') ->required() ->numeric() ->suffix('см') ->label('Высота'), Forms\Components\TextInput::make('width_cm') ->required() ->numeric() ->suffix('см') ->label('Ширина'), Forms\Components\Select::make('format_id') ->label('Формат') ->relationship('format', 'name') ->required() ->searchable() ->preload(), Forms\Components\Select::make('colors') ->label('Цвета') ->relationship('colors', 'name') ->multiple() ->searchable() ->preload() ->columnSpanFull(), Forms\Components\Repeater::make('flower_product_composition') ->relationship('flowerProductComposition') ->schema([ Forms\Components\Select::make('flower_id') ->label('Цветок') ->options(fn () => Flower::pluck('name', 'id')) ->required() ->searchable(), Forms\Components\TextInput::make('count') ->label('Количество') ->numeric() ->required() ->default(1), ]) ->columns(['md' => 2]) ->collapsible() ->label('Состав букета'), Forms\Components\Select::make('occasions') ->label('Поводы') ->relationship('occasions', 'name') ->multiple() ->searchable() ->preload() ->columnSpanFull(), ]) ->columns(['md' => 2]), Forms\Components\Section::make('SEO настройки') ->schema([ Forms\Components\TextInput::make('seo_title') ->maxLength(60) ->label('SEO Title') ->helperText('Максимум 60 символов'), Forms\Components\Textarea::make('seo_description') ->maxLength(160) ->label('SEO Description') ->helperText('Максимум 160 символов'), Forms\Components\TextInput::make('seo_keywords') ->label('SEO Keywords') ->helperText('Через запятую'), Actions::make([ Action::make('generate_seo') ->label('Сгенерировать SEO автоматически') ->color('success') ->icon('heroicon-o-sparkles') ->action(function (callable $set, callable $get) { $name = $get('name'); if (empty($name)) { Notification::make() ->title('Сначала заполните название товара') ->danger() ->send(); return; } self::generateSeoFields($set, $get, $name); Notification::make() ->title('SEO успешно сгенерировано') ->success() ->send(); }), ]), ]) ->columns(['md' => 2]), ]); } private static function generateSeoFields(callable $set, callable $get, string $productName): void { $composition = $get('flower_product_composition') ?? []; $occasionIds = $get('occasions') ?? []; $colorIds = $get('colors') ?? []; $format = $get('format_id'); $width = $get('width_cm') ?? ''; $height = $get('height_cm') ?? ''; $price = $get('price') ?? ''; $occasionNames = []; if (!empty($occasionIds)) { $occasionNames = is_array($occasionIds) ? Occasion::whereIn('id', $occasionIds)->pluck('name')->toArray() : [Occasion::find($occasionIds)?->name]; } $colorNames = []; if (is_array($colorIds)) { $colorNames = Color::whereIn('id', $colorIds)->pluck('name')->toArray(); } elseif (!empty($colorIds)) { $colorNames[] = Color::find($colorIds)?->name; } $seoData = SeoGeneratorService::generate([ 'name' => $productName, 'composition' => $composition, 'occasion' => $occasionNames, 'color' => implode(', ', $colorNames), 'format' => $format, 'width_cm' => $width, 'height_cm' => $height, 'price' => $price, ]); $set('seo_title', $seoData['seo_title'] ?? ''); $set('seo_description', $seoData['seo_description'] ?? ''); $set('seo_keywords', $seoData['seo_keywords'] ?? ''); $set('description', $seoData['description'] ?? ''); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\ImageColumn::make('main_image_jpg_small') ->label('Фото') ->disk('public') ->rounded() ->height(80) ->width(80) ->defaultImageUrl(function ($record) { if ($record->main_image_jpg_medium && Storage::disk('public')->exists($record->main_image_jpg_medium)) { return Storage::disk('public')->url($record->main_image_jpg_medium); } if ($record->main_image && Storage::disk('public')->exists($record->main_image)) { return Storage::disk('public')->url($record->main_image); } return null; }), Tables\Columns\TextColumn::make('name') ->label('Название') ->searchable() ->sortable() ->wrap(), // НОВАЯ КОЛОНКА ИНДИКАТОРА СОСТАВА IconColumn::make('has_composition') ->label('Состав') ->boolean() ->getStateUsing(fn ($record) => $record->flowers()->exists()) // Проверяет, есть ли привязанные цветы ->trueIcon('heroicon-o-check-circle') // Иконка, если состав есть ->falseIcon('heroicon-o-x-circle') // Иконка, если состава нет ->trueColor('success') // Зеленый цвет ->falseColor('danger') // Красный цвет ->sortable(query: function ($query, string $direction) { return $query->withCount('flowers')->orderBy('flowers_count', $direction); }), Tables\Columns\TextColumn::make('price') ->label('Цена') ->sortable(), ToggleColumn::make('in_stock') ->label('В наличии') ->sortable(), ToggleColumn::make('is_active') ->label('Активен') ->sortable(), Tables\Columns\TextColumn::make('created_at') ->label('Создан') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ SelectFilter::make('in_stock') ->label('Наличие') ->options([ true => 'В наличии', false => 'Нет в наличии', ]), SelectFilter::make('is_active') ->label('Активность') ->options([ true => 'Активен', false => 'Неактивен', ]), ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), Tables\Actions\BulkAction::make('setInStock') ->label('Отметить "В наличии"') ->icon('heroicon-o-check-circle') ->action(function ($records) { foreach ($records as $record) { $record->update(['in_stock' => true]); } }) ->deselectRecordsAfterCompletion(), Tables\Actions\BulkAction::make('setOutOfStock') ->label('Отметить "Нет в наличии"') ->icon('heroicon-o-x-circle') ->action(function ($records) { foreach ($records as $record) { $record->update(['in_stock' => false]); } }) ->deselectRecordsAfterCompletion(), Tables\Actions\BulkAction::make('activate') ->label('Активировать') ->icon('heroicon-o-check') ->action(function ($records) { foreach ($records as $record) { $record->update(['is_active' => true]); } }) ->deselectRecordsAfterCompletion(), Tables\Actions\BulkAction::make('deactivate') ->label('Деактивировать') ->icon('heroicon-o-x-mark') ->action(function ($records) { foreach ($records as $record) { $record->update(['is_active' => false]); } }) ->deselectRecordsAfterCompletion(), ]), ]) ->defaultSort('created_at', 'desc'); } public static function getRelations(): array { return [ CategoriesRelationManager::class, ]; } public static function getPages(): array { return [ 'index' => \App\Filament\Admin\Resources\Pages\ListProducts::route('/'), 'create' => \App\Filament\Admin\Resources\Pages\CreateProduct::route('/create'), 'edit' => \App\Filament\Admin\Resources\Pages\EditProduct::route('/{record}/edit'), ]; } } Страница не найдена
Логотип
Меню
Каталог

404

Ой! Эта страница завяла...

Кажется, мы не можем найти страницу, которую вы ищете. Возможно, она была удалена или вы ввели неправильный адрес.

Перейти в каталог
Telegram WhatsApp Max