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);
+});