diff --git a/database/migrations/2025_09_20_212732_create_pim_product_relations_table.php b/database/migrations/2025_09_20_212732_create_pim_product_relations_table.php new file mode 100644 index 0000000..913b026 --- /dev/null +++ b/database/migrations/2025_09_20_212732_create_pim_product_relations_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('parent_id') + ->constrained('pim_products', 'id') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->foreignId('child_id') + ->constrained('pim_products', 'id') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->string('type', 50); + $table->tinyInteger('sort')->nullable(); + $table->timestamps(); + $table->unique(['parent_id', 'child_id', 'type']); + $table->index(['parent_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pim_product_relations'); + } +}; diff --git a/resources/views/livewire/product-relations-table.blade.php b/resources/views/livewire/product-relations-table.blade.php new file mode 100644 index 0000000..126c29e --- /dev/null +++ b/resources/views/livewire/product-relations-table.blade.php @@ -0,0 +1 @@ + diff --git a/src/CatalogueServiceProvider.php b/src/CatalogueServiceProvider.php index b12a2c4..b72c357 100644 --- a/src/CatalogueServiceProvider.php +++ b/src/CatalogueServiceProvider.php @@ -12,7 +12,6 @@ use Eclipse\Catalogue\Filament\Resources\PropertyResource; use Eclipse\Catalogue\Filament\Resources\PropertyValueResource; use Eclipse\Catalogue\Filament\Resources\TaxClassResource; -use Eclipse\Catalogue\Livewire\TenantSwitcher; use Eclipse\Catalogue\Models\Category; use Eclipse\Catalogue\Models\Product; use Filament\Support\Assets\Css; @@ -176,8 +175,10 @@ public function boot() }); // Register Livewire components - if (class_exists(Livewire::class)) { - Livewire::component('eclipse-catalogue::tenant-switcher', TenantSwitcher::class); + if (class_exists(\Livewire\Livewire::class)) { + \Livewire\Livewire::component('eclipse-catalogue::tenant-switcher', \Eclipse\Catalogue\Livewire\TenantSwitcher::class); + \Livewire\Livewire::component('eclipse.catalogue.livewire.product-selector-table', \Eclipse\Catalogue\Livewire\ProductSelectorTable::class); + \Livewire\Livewire::component('eclipse.catalogue.livewire.product-relations-table', \Eclipse\Catalogue\Livewire\ProductRelationsTable::class); } FilamentAsset::register([ diff --git a/src/Enums/ProductRelationType.php b/src/Enums/ProductRelationType.php new file mode 100644 index 0000000..0a6f6f0 --- /dev/null +++ b/src/Enums/ProductRelationType.php @@ -0,0 +1,36 @@ + 'Related', + self::CROSS_SELL => 'Cross-sell', + self::UPSELL => 'Upsell', + }; + } + + /** + * Get the description for the product relation type. + */ + public function getDescription(): string + { + return match ($this) { + self::RELATED => 'Similar products that customers might be interested in', + self::CROSS_SELL => 'Complementary products or add-ons that enhance the main product', + self::UPSELL => 'Higher-priced or premium versions with more features', + }; + } +} diff --git a/src/Filament/Resources/ProductResource.php b/src/Filament/Resources/ProductResource.php index 80caef9..e0868c0 100644 --- a/src/Filament/Resources/ProductResource.php +++ b/src/Filament/Resources/ProductResource.php @@ -2,6 +2,7 @@ namespace Eclipse\Catalogue\Filament\Resources; +use Eclipse\Catalogue\Enums\ProductRelationType; use Eclipse\Catalogue\Enums\PropertyInputType; use Eclipse\Catalogue\Filament\Filters\CustomPropertyConstraint; use Eclipse\Catalogue\Filament\Forms\Components\ImageManager; @@ -566,6 +567,47 @@ function ($query) { ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp']) ->columnSpanFull(), ]), + + Tabs\Tab::make('Related Products') + ->schema([ + Tabs::make('product_relations_tabs') + ->tabs([ + Tabs\Tab::make('related') + ->label('Related') + ->icon('heroicon-o-link') + ->schema([ + View::make('eclipse-catalogue::livewire.product-relations-table') + ->viewData(fn (?Product $record) => [ + 'productId' => $record?->id, + 'type' => ProductRelationType::RELATED->value, + ]), + ]), + + Tabs\Tab::make('cross_sell') + ->label('Cross-sell') + ->icon('heroicon-o-plus-circle') + ->schema([ + View::make('eclipse-catalogue::livewire.product-relations-table') + ->viewData(fn (?Product $record) => [ + 'productId' => $record?->id, + 'type' => ProductRelationType::CROSS_SELL->value, + ]), + ]), + + Tabs\Tab::make('upsell') + ->label('Upsell') + ->icon('heroicon-o-arrow-trending-up') + ->schema([ + View::make('eclipse-catalogue::livewire.product-relations-table') + ->viewData(fn (?Product $record) => [ + 'productId' => $record?->id, + 'type' => ProductRelationType::UPSELL->value, + ]), + ]), + ]) + ->columnSpanFull(), + ]) + ->visible(fn (?Product $record) => $record && $record->exists), ]) ->columnSpanFull(), ]); @@ -1032,6 +1074,38 @@ protected static function getCustomPropertyColumns(): array return $columns; } + protected static function getRelationsColumns(): array + { + return [ + TextColumn::make('related_count') + ->label('Related') + ->getStateUsing(function (Product $record) { + return $record->related()->count(); + }) + ->badge() + ->color('gray') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('cross_sell_count') + ->label('Cross-sell') + ->getStateUsing(function (Product $record) { + return $record->crossSell()->count(); + }) + ->badge() + ->color('blue') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('upsell_count') + ->label('Upsell') + ->getStateUsing(function (Product $record) { + return $record->upsell()->count(); + }) + ->badge() + ->color('green') + ->toggleable(isToggledHiddenByDefault: true), + ]; + } + protected static function getCustomPropertyConstraints(): array { $constraints = []; diff --git a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php index ade8f0b..4d93eca 100644 --- a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php @@ -52,7 +52,7 @@ protected function getFormMutuallyExclusiveFlagSets(): array return []; } - public function form(Schema $schema): Schema + public function schema(Schema $schema): Schema { return $schema; } diff --git a/src/Filament/Resources/ProductResource/Pages/EditProduct.php b/src/Filament/Resources/ProductResource/Pages/EditProduct.php index c883989..6e43765 100644 --- a/src/Filament/Resources/ProductResource/Pages/EditProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/EditProduct.php @@ -2,8 +2,10 @@ namespace Eclipse\Catalogue\Filament\Resources\ProductResource\Pages; +use Eclipse\Catalogue\Enums\ProductRelationType; use Eclipse\Catalogue\Filament\Resources\ProductResource; use Eclipse\Catalogue\Models\Group; +use Eclipse\Catalogue\Models\ProductRelation; use Eclipse\Catalogue\Models\Property; use Eclipse\Catalogue\Models\PropertyValue; use Eclipse\Catalogue\Traits\HandlesTenantData; @@ -17,6 +19,7 @@ use Filament\Resources\Pages\EditRecord; use Filament\Schemas\Schema; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Log; use LaraZeus\SpatieTranslatable\Actions\LocaleSwitcher; use LaraZeus\SpatieTranslatable\Resources\Pages\EditRecord\Concerns\Translatable; use Nben\FilamentRecordNav\Actions\NextRecordAction; @@ -146,6 +149,13 @@ protected function afterSave(): void { if ($this->record) { $state = $this->form->getRawState(); + + $this->saveProductRelations([ + 'related_products' => $this->data['related_products'] ?? [], + 'cross_sell_products' => $this->data['cross_sell_products'] ?? [], + 'upsell_products' => $this->data['upsell_products'] ?? [], + ]); + $propertyData = []; foreach ($state as $key => $value) { if (is_string($key) && str_starts_with($key, 'property_values_')) { @@ -217,7 +227,7 @@ protected function getFormMutuallyExclusiveFlagSets(): array return []; } - public function form(Schema $schema): Schema + public function schema(Schema $schema): Schema { return $schema; } @@ -335,4 +345,59 @@ public function reorderImages(string $statePath, array $uuids): void ->values() ->toArray(); } + + protected function saveProductRelations(array $state): void + { + if (! $this->record) { + return; + } + + $relationTypes = [ + 'related_products' => ProductRelationType::RELATED, + 'cross_sell_products' => ProductRelationType::CROSS_SELL, + 'upsell_products' => ProductRelationType::UPSELL, + ]; + + foreach ($relationTypes as $fieldName => $relationType) { + $relationItems = $state[$fieldName] ?? []; + + if (empty($relationItems)) { + continue; + } + + ProductRelation::where('parent_id', $this->record->id) + ->where('type', $relationType->value) + ->delete(); + + $position = 1; + foreach ($relationItems as $item) { + if (! is_array($item) || ! isset($item['product_id']) || empty($item['product_id'])) { + continue; + } + + $childId = is_numeric($item['product_id']) ? (int) $item['product_id'] : null; + + if ($childId && $childId !== $this->record->id) { + try { + ProductRelation::create([ + 'parent_id' => $this->record->id, + 'child_id' => $childId, + 'type' => $relationType->value, + 'sort' => $position, + ]); + $position++; + } catch (\Exception $e) { + Log::error('Failed to create product relation', [ + 'parent_id' => $this->record->id, + 'child_id' => $childId, + 'type' => $relationType->value, + 'sort' => $position, + 'item' => $item, + 'error' => $e->getMessage(), + ]); + } + } + } + } + } } diff --git a/src/Filament/Resources/ProductSelectorResource.php b/src/Filament/Resources/ProductSelectorResource.php new file mode 100644 index 0000000..5d8e858 --- /dev/null +++ b/src/Filament/Resources/ProductSelectorResource.php @@ -0,0 +1,133 @@ +columns([ + ImageColumn::make('cover_image') + ->label('') + ->disk('public') + ->size(40) + ->defaultImageUrl('/images/placeholder-product.png'), + + TextColumn::make('code') + ->label('Code') + ->searchable() + ->sortable() + ->toggleable(), + + TextColumn::make('name') + ->label('Name') + ->searchable() + ->sortable() + ->limit(50) + ->tooltip(function (TextColumn $column): ?string { + $state = $column->getState(); + if (is_array($state)) { + $state = $state[app()->getLocale()] ?? reset($state); + } + + return strlen($state) > 50 ? $state : null; + }), + + TextColumn::make('type.name') + ->label('Type') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('category.name') + ->label('Category') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('status.name') + ->label('Status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'Active' => 'success', + 'Inactive' => 'danger', + 'Draft' => 'warning', + default => 'gray', + }), + ]) + ->filters([ + SelectFilter::make('product_type_id') + ->label('Type') + ->relationship('type', 'name') + ->searchable() + ->preload(), + + SelectFilter::make('category_id') + ->label('Category') + ->options(function () { + return Category::query() + ->orderBy('name') + ->pluck('name', 'id') + ->toArray(); + }) + ->searchable(), + + SelectFilter::make('status') + ->label('Status') + ->options(function () { + return ProductStatus::query() + ->orderBy('name') + ->pluck('name', 'id') + ->toArray(); + }), + + SelectFilter::make('group') + ->label('Group') + ->relationship('groups', 'name') + ->searchable() + ->preload(), + ]) + ->defaultSort('name') + ->striped() + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(10); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->with(['type', 'category', 'status', 'media']) + ->whereNotNull('name'); + } + + /** + * Get query excluding specific product IDs. + */ + public static function getEloquentQueryExcluding(array $excludeIds = []): Builder + { + $query = static::getEloquentQuery(); + + if (! empty($excludeIds)) { + $query->whereNotIn('id', $excludeIds); + } + + return $query; + } +} diff --git a/src/Livewire/ProductRelationsTable.php b/src/Livewire/ProductRelationsTable.php new file mode 100644 index 0000000..8bb66e1 --- /dev/null +++ b/src/Livewire/ProductRelationsTable.php @@ -0,0 +1,137 @@ +value; + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + public function mount(int $productId, string $type): void + { + $this->productId = $productId; + $this->type = $type; + } + + public function table(Tables\Table $table): Tables\Table + { + return $table + ->query($this->getRelationsQuery()) + ->columns([ + TextColumn::make('child_code') + ->label('Code') + ->searchable() + ->sortable() + ->getStateUsing(function (ProductRelation $record) { + return $record->child?->code ?? 'N/A'; + }), + TextColumn::make('child_name') + ->label('Name') + ->searchable() + ->sortable() + ->getStateUsing(function (ProductRelation $record) { + if (! $record->child) { + return 'N/A'; + } + $name = is_array($record->child->name) + ? ($record->child->name[app()->getLocale()] ?? reset($record->child->name)) + : $record->child->name; + + return $name; + }), + ]) + ->headerActions([ + Action::make('add') + ->label('Add products') + ->icon('heroicon-o-plus') + ->modalHeading('Select products') + ->modalWidth('full') + ->modalContent(fn () => new \Illuminate\Support\HtmlString( + \Livewire\Livewire::mount('eclipse.catalogue.livewire.product-selector-table', [ + 'productId' => $this->productId, + 'type' => $this->type, + ]) + )) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Cancel') + ->closeModalByClickingAway(false), + ]) + ->actions([ + Action::make('edit_product') + ->label('Edit Product') + ->icon('heroicon-o-pencil') + ->url(fn ($record): string => \Eclipse\Catalogue\Filament\Resources\ProductResource::getUrl('edit', ['record' => $record->child_id])) + ->openUrlInNewTab(), + + DeleteAction::make() + ->label('Remove') + ->modalHeading(fn ($record) => 'Remove '.($record->child?->name ?? 'product')) + ->modalSubmitActionLabel('Remove') + ->modalCancelActionLabel('Cancel'), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make() + ->label('Remove') + ->modalHeading('Remove selected') + ->modalSubmitActionLabel('Remove') + ->modalCancelActionLabel('Cancel'), + ]), + ]) + ->reorderable('sort') + ->reorderRecordsTriggerAction( + fn (Action $action, bool $isReordering) => $action + ->button() + ->label($isReordering ? 'Disable reordering' : 'Enable reordering') + ->icon($isReordering ? 'heroicon-o-x-mark' : 'heroicon-o-arrows-up-down') + ->color($isReordering ? 'danger' : 'primary') + ) + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(10); + } + + protected function getRelationsQuery(): Builder + { + return ProductRelation::query() + ->where('parent_id', $this->productId) + ->where('type', $this->type) + ->with('child') + ->orderBy('sort') + ->orderBy('id'); + } + + public function render() + { + return <<<'blade' +
+ {{ $this->table }} +
+ blade; + } +} diff --git a/src/Livewire/ProductSelectorTable.php b/src/Livewire/ProductSelectorTable.php new file mode 100644 index 0000000..1895f89 --- /dev/null +++ b/src/Livewire/ProductSelectorTable.php @@ -0,0 +1,395 @@ +productId = $productId; + $this->type = $type; + } + + public function toggleProductSelection(int $productId): void + { + if (in_array($productId, $this->persistentSelection)) { + $this->persistentSelection = array_diff($this->persistentSelection, [$productId]); + } else { + $this->persistentSelection[] = $productId; + } + } + + public function isProductSelected(int $productId): bool + { + return in_array($productId, $this->persistentSelection); + } + + public function table(Tables\Table $table): Tables\Table + { + return $table + ->query($this->getProductsQuery()) + ->columns([ + Tables\Columns\CheckboxColumn::make('selected') + ->label('') + ->getStateUsing(fn (Product $record) => $this->isProductSelected($record->id)) + ->updateStateUsing(fn ($record, $state) => $this->toggleProductSelection($record->id)) + ->width('60px') + ->alignCenter() + ->sortable(false), + TextColumn::make('code') + ->label('Code') + ->searchable() + ->sortable() + ->weight('medium'), + TextColumn::make('name') + ->label('Name') + ->searchable() + ->sortable() + ->weight('medium') + ->getStateUsing(function (Product $record) { + $name = is_array($record->name) + ? ($record->name[app()->getLocale()] ?? reset($record->name)) + : $record->name; + + return $name; + }), + TextColumn::make('category') + ->label('Category') + ->getStateUsing(function (Product $record) { + $category = $record->currentTenantData()?->category; + if (! $category) { + return null; + } + + return is_array($category->name) ? ($category->name[app()->getLocale()] ?? reset($category->name)) : $category->name; + }) + ->searchable() + ->sortable() + ->toggleable(), + TextColumn::make('status') + ->label(__('eclipse-catalogue::product-status.singular')) + ->badge() + ->getStateUsing(function (Product $record) { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + + $status = null; + + if ($record->relationLoaded('productData')) { + $row = $record->productData + ->when($tenantFK && $currentTenant, fn ($c) => $c->where($tenantFK, $currentTenant->id)) + ->first(); + if ($row && $row->relationLoaded('status')) { + $status = $row->status; + } + } + + if (! $status) { + return __('eclipse-catalogue::product-status.fields.no_status') ?? 'No status'; + } + + return is_array($status->title) ? ($status->title[app()->getLocale()] ?? reset($status->title)) : $status->title; + }) + ->color(function (Product $record) { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + + $status = null; + if ($record->relationLoaded('productData')) { + $row = $record->productData + ->when($tenantFK && $currentTenant, fn ($c) => $c->where($tenantFK, $currentTenant->id)) + ->first(); + if ($row && $row->relationLoaded('status')) { + $status = $row->status; + } + } + + return $status?->label_type ?? 'gray'; + }) + ->extraAttributes(function (Product $record) { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + + $status = null; + if ($record->relationLoaded('productData')) { + $row = $record->productData + ->when($tenantFK && $currentTenant, fn ($c) => $c->where($tenantFK, $currentTenant->id)) + ->first(); + if ($row && $row->relationLoaded('status')) { + $status = $row->status; + } + } + + return $status ? ['class' => \Eclipse\Catalogue\Support\LabelType::badgeClass($status->label_type)] : []; + }) + ->searchable(false) + ->sortable(false) + ->toggleable(), + TextColumn::make('type.name') + ->label(__('eclipse-catalogue::product.table.columns.type')) + ->searchable() + ->sortable() + ->toggleable(), + IconColumn::make('free_delivery') + ->label('Free Delivery') + ->sortable() + ->toggleable() + ->getStateUsing(function (Product $record) { + $tenantData = $record->currentTenantData(); + + return $tenantData?->has_free_delivery ?? false; + }) + ->boolean(), + TextColumn::make('groups') + ->label('Groups') + ->badge() + ->separator(',') + ->searchable() + ->toggleable() + ->getStateUsing(function (Product $record) { + $currentTenant = \Filament\Facades\Filament::getTenant(); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + + if ($currentTenant) { + return $record->groups() + ->where($tenantFK, $currentTenant->id) + ->pluck('name') + ->toArray(); + } + + return $record->groups->pluck('name')->toArray(); + }), + ]) + ->filters([ + SelectFilter::make('category_id') + ->label('Categories') + ->multiple() + ->options(Category::getHierarchicalOptions()) + ->query(function (Builder $query, array $data) { + $selected = $data['values'] ?? ($data['value'] ?? null); + if (empty($selected)) { + return; + } + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + $query->whereHas('productData', function ($q) use ($selected, $tenantFK, $currentTenant) { + if ($tenantFK && $currentTenant) { + $q->where($tenantFK, $currentTenant->id); + } + $q->whereIn('category_id', (array) $selected); + }); + }), + SelectFilter::make('product_status_id') + ->label('Status') + ->multiple() + ->options(function () { + $query = ProductStatus::query(); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + + if ($tenantFK && $currentTenant) { + $query->where($tenantFK, $currentTenant->id); + } + + return $query->orderBy('priority')->get()->mapWithKeys(function ($status) { + $title = is_array($status->title) + ? ($status->title[app()->getLocale()] ?? reset($status->title)) + : $status->title; + + return [$status->id => $title]; + })->toArray(); + }) + ->query(function (Builder $query, array $data) { + $selected = $data['values'] ?? ($data['value'] ?? null); + if (empty($selected)) { + return; + } + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + $query->whereHas('productData', function ($q) use ($selected, $tenantFK, $currentTenant) { + if ($tenantFK && $currentTenant) { + $q->where($tenantFK, $currentTenant->id); + } + $q->whereIn('product_status_id', (array) $selected); + }); + }), + SelectFilter::make('product_type_id') + ->label('Product Type') + ->multiple() + ->options(function () { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + + $query = ProductType::query(); + + if ($tenantFK && $currentTenant) { + $query->whereHas('productTypeData', function ($q) use ($tenantFK, $currentTenant) { + $q->where($tenantFK, $currentTenant->id) + ->where('is_active', true); + }); + } else { + $query->whereHas('productTypeData', function ($q) { + $q->where('is_active', true); + }); + } + + return $query->pluck('name', 'id')->toArray(); + }) + ->query(function (Builder $query, array $data) { + $selected = $data['values'] ?? ($data['value'] ?? null); + if (empty($selected)) { + return; + } + $query->whereIn('product_type_id', (array) $selected); + }), + SelectFilter::make('free_delivery') + ->label('Free Delivery') + ->options([ + '1' => 'Yes', + '0' => 'No', + ]) + ->query(function (Builder $query, array $data) { + $selected = $data['values'] ?? ($data['value'] ?? null); + if (empty($selected)) { + return; + } + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + $query->whereHas('productData', function ($q) use ($selected, $tenantFK, $currentTenant) { + if ($tenantFK && $currentTenant) { + $q->where($tenantFK, $currentTenant->id); + } + $q->whereIn('has_free_delivery', (array) $selected); + }); + }), + SelectFilter::make('groups') + ->label('Groups') + ->multiple() + ->relationship('groups', 'name', function ($query) { + $currentTenant = \Filament\Facades\Filament::getTenant(); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + if ($currentTenant) { + return $query->where($tenantFK, $currentTenant->id); + } + + return $query; + }), + ]) + ->filtersFormColumns(2) + ->recordUrl(null) + ->paginated([10, 25, 50, 100]) + ->defaultPaginationPageOption(10) + ->headerActions([ + Action::make('add_selected') + ->label(fn () => 'Add selected products ('.count($this->persistentSelection).')') + ->icon('heroicon-o-plus') + ->disabled(fn () => empty($this->persistentSelection)) + ->action(function () { + $ids = $this->persistentSelection; + $count = count($ids); + + if (empty($ids)) { + Notification::make() + ->title('No products selected') + ->body('Please select at least one product to add.') + ->warning() + ->send(); + + return; + } + + ProductRelationService::addBuffered($this->productId, $this->type, $ids); + + Notification::make() + ->title('Products added successfully') + ->body("Added {$count} ".strtolower($this->type).' product'.($count === 1 ? '' : 's')) + ->success() + ->send(); + + $this->persistentSelection = []; + + $this->dispatch('relations-updated'); + }), + ]) + ->bulkActions([]) + ->extremePaginationLinks() + ->defaultSort('id', 'asc') + ->striped() + ->persistFiltersInSession(); + } + + protected function getProductsQuery(): Builder + { + $excludeIds = ProductRelation::query() + ->where('parent_id', $this->productId) + ->where('type', $this->type) + ->pluck('child_id') + ->toArray(); + + $excludeIds[] = $this->productId; + + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + + $query = Product::query() + ->withoutGlobalScopes([ + \Illuminate\Database\Eloquent\SoftDeletingScope::class, + ]) + ->whereNotIn('id', $excludeIds) + ->with(['type', 'groups']); + + if ($tenantFK && $currentTenant) { + $query->with(['productData' => function ($q) use ($tenantFK, $currentTenant) { + $q->where($tenantFK, $currentTenant->id)->with(['category', 'status']); + }]); + } else { + $query->with(['productData.category', 'productData.status']); + } + + return $query; + } + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + public function render() + { + return <<<'blade' +
+ {{ $this->table }} +
+ blade; + } +} diff --git a/src/Models/Product.php b/src/Models/Product.php index 1e5f1d4..a780c36 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -146,6 +146,50 @@ public function groups(): BelongsToMany ->withPivot('sort'); } + /** + * Get all relations where this product is the parent. + */ + public function relations(): HasMany + { + return $this->hasMany(ProductRelation::class, 'parent_id'); + } + + /** + * Get related products. + */ + public function related(): BelongsToMany + { + return $this->belongsToMany(self::class, 'pim_product_relations', 'parent_id', 'child_id') + ->wherePivot('type', \Eclipse\Catalogue\Enums\ProductRelationType::RELATED->value) + ->withPivot('sort') + ->orderByPivot('sort') + ->orderByPivot('id'); + } + + /** + * Get cross-sell products. + */ + public function crossSell(): BelongsToMany + { + return $this->belongsToMany(self::class, 'pim_product_relations', 'parent_id', 'child_id') + ->wherePivot('type', \Eclipse\Catalogue\Enums\ProductRelationType::CROSS_SELL->value) + ->withPivot('sort') + ->orderByPivot('sort') + ->orderByPivot('id'); + } + + /** + * Get upsell products. + */ + public function upsell(): BelongsToMany + { + return $this->belongsToMany(self::class, 'pim_product_relations', 'parent_id', 'child_id') + ->wherePivot('type', \Eclipse\Catalogue\Enums\ProductRelationType::UPSELL->value) + ->withPivot('sort') + ->orderByPivot('sort') + ->orderByPivot('id'); + } + public function getIsActiveAttribute(): bool { return $this->getTenantFlagValue('is_active'); diff --git a/src/Models/ProductRelation.php b/src/Models/ProductRelation.php new file mode 100644 index 0000000..e722b88 --- /dev/null +++ b/src/Models/ProductRelation.php @@ -0,0 +1,78 @@ + ProductRelationType::class, + 'sort' => 'integer', + ]; + + /** + * Get the parent product. + */ + public function parent(): BelongsTo + { + return $this->belongsTo(Product::class, 'parent_id'); + } + + /** + * Get the child product. + */ + public function child(): BelongsTo + { + return $this->belongsTo(Product::class, 'child_id'); + } + + /** + * Boot the model. + */ + protected static function boot() + { + parent::boot(); + + static::saving(function (ProductRelation $relation) { + if ($relation->parent_id === $relation->child_id) { + throw new \InvalidArgumentException('A product cannot be related to itself.'); + } + }); + } + + /** + * Scope to get relations by type. + */ + public function scopeOfType($query, ProductRelationType $type) + { + return $query->where('type', $type->value); + } + + /** + * Scope to get relations for a specific parent product. + */ + public function scopeForParent($query, int $parentId) + { + return $query->where('parent_id', $parentId); + } + + /** + * Scope to get relations ordered by sort. + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort')->orderBy('id'); + } +} diff --git a/src/Services/ProductRelationService.php b/src/Services/ProductRelationService.php new file mode 100644 index 0000000..e22f088 --- /dev/null +++ b/src/Services/ProductRelationService.php @@ -0,0 +1,35 @@ +where('parent_id', $parentId) + ->where('type', $type) + ->max('sort') ?? 0) + 1; + + foreach ($ids as $id) { + if (! is_numeric($id)) { + continue; + } + ProductRelation::firstOrCreate([ + 'parent_id' => $parentId, + 'child_id' => (int) $id, + 'type' => $type, + ], [ + 'sort' => $next++, + ]); + } + } +} diff --git a/tests/Unit/ProductRelationTest.php b/tests/Unit/ProductRelationTest.php new file mode 100644 index 0000000..6ad8c62 --- /dev/null +++ b/tests/Unit/ProductRelationTest.php @@ -0,0 +1,185 @@ +migrate(); + $this->setUpSuperAdminAndTenant(); +}); + +it('can create product relations', function () { + $parent = Product::factory()->create(); + $child = Product::factory()->create(); + + $relation = ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 1, + ]); + + expect($relation->exists)->toBeTrue(); + expect($relation->type)->toBe(ProductRelationType::RELATED); + expect($relation->parent_id)->toBe($parent->id); + expect($relation->child_id)->toBe($child->id); +}); + +it('prevents self-relations', function () { + $product = Product::factory()->create(); + + expect(function () use ($product) { + ProductRelation::create([ + 'parent_id' => $product->id, + 'child_id' => $product->id, + 'type' => ProductRelationType::RELATED, + ]); + })->toThrow(\InvalidArgumentException::class, 'A product cannot be related to itself.'); +}); + +it('enforces unique constraint', function () { + $parent = Product::factory()->create(); + $child = Product::factory()->create(); + + // Create first relation + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 1, + ]); + + // Attempt to create duplicate relation + expect(function () use ($parent, $child) { + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 2, + ]); + })->toThrow(\Illuminate\Database\QueryException::class); +}); + +it('allows same products with different relation types', function () { + $parent = Product::factory()->create(); + $child = Product::factory()->create(); + + $relatedRelation = ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 1, + ]); + + $crossSellRelation = ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::CROSS_SELL, + 'sort' => 1, + ]); + + expect($relatedRelation->exists)->toBeTrue(); + expect($crossSellRelation->exists)->toBeTrue(); +}); + +it('has proper relationships', function () { + $parent = Product::factory()->create(); + $child = Product::factory()->create(); + + $relation = ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::UPSELL, + ]); + + expect($relation->parent->id)->toBe($parent->id); + expect($relation->child->id)->toBe($child->id); +}); + +it('can scope by type', function () { + $parent = Product::factory()->create(); + $child1 = Product::factory()->create(); + $child2 = Product::factory()->create(); + + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child1->id, + 'type' => ProductRelationType::RELATED, + ]); + + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child2->id, + 'type' => ProductRelationType::CROSS_SELL, + ]); + + $relatedRelations = ProductRelation::ofType(ProductRelationType::RELATED)->get(); + $crossSellRelations = ProductRelation::ofType(ProductRelationType::CROSS_SELL)->get(); + + expect($relatedRelations->count())->toBe(1); + expect($crossSellRelations->count())->toBe(1); + expect($relatedRelations->first()->child_id)->toBe($child1->id); + expect($crossSellRelations->first()->child_id)->toBe($child2->id); +}); + +it('can scope by parent', function () { + $parent1 = Product::factory()->create(); + $parent2 = Product::factory()->create(); + $child = Product::factory()->create(); + + ProductRelation::create([ + 'parent_id' => $parent1->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::RELATED, + ]); + + ProductRelation::create([ + 'parent_id' => $parent2->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::RELATED, + ]); + + $parent1Relations = ProductRelation::forParent($parent1->id)->get(); + $parent2Relations = ProductRelation::forParent($parent2->id)->get(); + + expect($parent1Relations->count())->toBe(1); + expect($parent2Relations->count())->toBe(1); +}); + +it('orders by sort and id', function () { + $parent = Product::factory()->create(); + $child1 = Product::factory()->create(); + $child2 = Product::factory()->create(); + $child3 = Product::factory()->create(); + + // Create in random order + $relation2 = ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child2->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 2, + ]); + + $relation1 = ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child1->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 1, + ]); + + $relation3 = ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child3->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 1, + ]); + + $orderedRelations = ProductRelation::forParent($parent->id)->ordered()->get(); + + expect($orderedRelations->count())->toBe(3); + // First should be the one with sort=1 and lower ID + expect($orderedRelations->first()->id)->toBe($relation1->id); + // Last should be the one with sort=2 + expect($orderedRelations->last()->id)->toBe($relation2->id); +}); diff --git a/tests/Unit/ProductRelationshipTest.php b/tests/Unit/ProductRelationshipTest.php new file mode 100644 index 0000000..4fda348 --- /dev/null +++ b/tests/Unit/ProductRelationshipTest.php @@ -0,0 +1,180 @@ +migrate(); + $this->setUpSuperAdminAndTenant(); +}); + +it('has related products relationship', function () { + $parent = Product::factory()->create(); + $child1 = Product::factory()->create(); + $child2 = Product::factory()->create(); + + // Create related products + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child1->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 1, + ]); + + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child2->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 2, + ]); + + // Create non-related product (cross-sell) + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => Product::factory()->create()->id, + 'type' => ProductRelationType::CROSS_SELL, + 'sort' => 1, + ]); + + $relatedProducts = $parent->related; + + expect($relatedProducts->count())->toBe(2); + expect($relatedProducts->pluck('id')->toArray())->toEqual([$child1->id, $child2->id]); +}); + +it('has cross-sell products relationship', function () { + $parent = Product::factory()->create(); + $child1 = Product::factory()->create(); + $child2 = Product::factory()->create(); + + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child1->id, + 'type' => ProductRelationType::CROSS_SELL, + 'sort' => 2, + ]); + + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child2->id, + 'type' => ProductRelationType::CROSS_SELL, + 'sort' => 1, + ]); + + $crossSellProducts = $parent->crossSell; + + expect($crossSellProducts->count())->toBe(2); + // Should be ordered by sort + expect($crossSellProducts->first()->id)->toBe($child2->id); + expect($crossSellProducts->last()->id)->toBe($child1->id); +}); + +it('has upsell products relationship', function () { + $parent = Product::factory()->create(); + $child = Product::factory()->create(); + + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::UPSELL, + 'sort' => 1, + ]); + + $upsellProducts = $parent->upsell; + + expect($upsellProducts->count())->toBe(1); + expect($upsellProducts->first()->id)->toBe($child->id); +}); + +it('has relations relationship', function () { + $parent = Product::factory()->create(); + $child1 = Product::factory()->create(); + $child2 = Product::factory()->create(); + + $relation1 = ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child1->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 1, + ]); + + $relation2 = ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child2->id, + 'type' => ProductRelationType::CROSS_SELL, + 'sort' => 1, + ]); + + $allRelations = $parent->relations; + + expect($allRelations->count())->toBe(2); + expect($allRelations->pluck('id')->toArray())->toContain($relation1->id, $relation2->id); +}); + +it('orders relationships by sort and id', function () { + $parent = Product::factory()->create(); + $child1 = Product::factory()->create(); + $child2 = Product::factory()->create(); + $child3 = Product::factory()->create(); + + // Create with different sort orders + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child2->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 3, + ]); + + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child1->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 1, + ]); + + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child3->id, + 'type' => ProductRelationType::RELATED, + 'sort' => 2, + ]); + + $relatedProducts = $parent->related; + + expect($relatedProducts->count())->toBe(3); + expect($relatedProducts->pluck('id')->toArray())->toEqual([ + $child1->id, // sort: 1 + $child3->id, // sort: 2 + $child2->id, // sort: 3 + ]); +}); + +it('relationships are separate by type', function () { + $parent = Product::factory()->create(); + $child = Product::factory()->create(); + + // Create all three types of relations with the same child + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::RELATED, + ]); + + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::CROSS_SELL, + ]); + + ProductRelation::create([ + 'parent_id' => $parent->id, + 'child_id' => $child->id, + 'type' => ProductRelationType::UPSELL, + ]); + + expect($parent->related->count())->toBe(1); + expect($parent->crossSell->count())->toBe(1); + expect($parent->upsell->count())->toBe(1); + expect($parent->relations->count())->toBe(3); +});