Align commerce module with the monorepo module structure by updating all namespaces to use the Core\Mod\Commerce convention. This change supports the recent monorepo separation and ensures consistency with other modules. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
423 lines
13 KiB
PHP
423 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Mod\Commerce\View\Modal\Admin;
|
|
|
|
use Core\Mod\Commerce\Models\Entity;
|
|
use Core\Mod\Commerce\Models\Product;
|
|
use Core\Mod\Commerce\Models\ProductAssignment;
|
|
use Core\Mod\Commerce\Services\ProductCatalogService;
|
|
use Illuminate\Contracts\View\View;
|
|
use Livewire\Attributes\Computed;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
#[Layout('hub::admin.layouts.app')]
|
|
class ProductManager extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
// Filters
|
|
#[Url]
|
|
public ?int $entityId = null;
|
|
|
|
#[Url]
|
|
public string $search = '';
|
|
|
|
#[Url]
|
|
public string $category = '';
|
|
|
|
#[Url]
|
|
public string $stockFilter = '';
|
|
|
|
// Modal state
|
|
public bool $showModal = false;
|
|
|
|
public bool $showAssignModal = false;
|
|
|
|
public ?int $editingId = null;
|
|
|
|
// Product form
|
|
public array $form = [
|
|
'sku' => '',
|
|
'name' => '',
|
|
'description' => '',
|
|
'short_description' => '',
|
|
'category' => '',
|
|
'subcategory' => '',
|
|
'price' => 0,
|
|
'cost_price' => null,
|
|
'rrp' => null,
|
|
'currency' => 'GBP',
|
|
'tax_class' => 'standard',
|
|
'type' => 'simple',
|
|
'track_stock' => true,
|
|
'stock_quantity' => 0,
|
|
'low_stock_threshold' => 5,
|
|
'allow_backorder' => false,
|
|
'is_active' => true,
|
|
'is_featured' => false,
|
|
'is_visible' => true,
|
|
];
|
|
|
|
// Assignment form
|
|
public array $assignForm = [
|
|
'entity_id' => null,
|
|
'product_id' => null,
|
|
'price_override' => null,
|
|
'margin_percent' => null,
|
|
'name_override' => '',
|
|
'description_override' => '',
|
|
'is_active' => true,
|
|
'is_featured' => false,
|
|
];
|
|
|
|
protected ProductCatalogService $catalog;
|
|
|
|
public function boot(ProductCatalogService $catalog): void
|
|
{
|
|
$this->catalog = $catalog;
|
|
}
|
|
|
|
#[Computed]
|
|
public function selectedEntity(): ?Entity
|
|
{
|
|
return $this->entityId ? Entity::find($this->entityId) : null;
|
|
}
|
|
|
|
#[Computed]
|
|
public function masterEntities()
|
|
{
|
|
return Entity::masters()->active()->get();
|
|
}
|
|
|
|
#[Computed]
|
|
public function allEntities()
|
|
{
|
|
return Entity::active()->orderBy('path')->get();
|
|
}
|
|
|
|
#[Computed]
|
|
public function categories(): array
|
|
{
|
|
$query = Product::query()->distinct();
|
|
if ($this->entityId) {
|
|
$entity = Entity::find($this->entityId);
|
|
if ($entity?->isM1()) {
|
|
$query->where('owner_entity_id', $this->entityId);
|
|
}
|
|
}
|
|
|
|
$categories = $query->pluck('category')->filter()->unique()->sort()->values()->toArray();
|
|
|
|
// Convert to associative array for filter component
|
|
return array_combine($categories, $categories);
|
|
}
|
|
|
|
#[Computed]
|
|
public function stockFilters(): array
|
|
{
|
|
return [
|
|
'in_stock' => __('commerce::commerce.filters.in_stock'),
|
|
'low_stock' => __('commerce::commerce.filters.low_stock'),
|
|
'out_of_stock' => __('commerce::commerce.filters.out_of_stock'),
|
|
'backorder' => __('commerce::commerce.filters.backorder'),
|
|
];
|
|
}
|
|
|
|
#[Computed]
|
|
public function entityOptions(): array
|
|
{
|
|
return $this->masterEntities->mapWithKeys(function ($entity) {
|
|
return [$entity->id => "{$entity->code} - {$entity->name}"];
|
|
})->all();
|
|
}
|
|
|
|
#[Computed]
|
|
public function tableColumns(): array
|
|
{
|
|
return [
|
|
__('commerce::commerce.table.product'),
|
|
__('commerce::commerce.table.sku'),
|
|
['label' => __('commerce::commerce.table.price'), 'align' => 'right'],
|
|
__('commerce::commerce.table.stock'),
|
|
['label' => __('commerce::commerce.table.status'), 'align' => 'center'],
|
|
__('commerce::commerce.table.assignments'),
|
|
['label' => __('commerce::commerce.table.actions'), 'align' => 'center'],
|
|
];
|
|
}
|
|
|
|
#[Computed]
|
|
public function tableRows(): array
|
|
{
|
|
$stockColors = [
|
|
'in_stock' => 'green',
|
|
'low_stock' => 'amber',
|
|
'out_of_stock' => 'red',
|
|
'backorder' => 'blue',
|
|
];
|
|
|
|
return $this->products->map(function ($product) use ($stockColors) {
|
|
$assignments = $this->getAssignmentsForProduct($product->id);
|
|
|
|
// Build product name lines with image
|
|
$productLines = [
|
|
['bold' => $product->name],
|
|
['muted' => $product->category ?? __('commerce::commerce.products.uncategorised')],
|
|
];
|
|
|
|
// Build price lines
|
|
$priceLines = [['bold' => $product->formatted_price]];
|
|
if ($product->cost_price) {
|
|
$priceLines[] = ['muted' => 'Cost: £'.number_format($product->cost_price / 100, 2)];
|
|
}
|
|
|
|
// Build stock display
|
|
$stockCell = $product->track_stock
|
|
? ['badge' => $product->stock_quantity.' '.__('commerce::commerce.products.units'), 'color' => $stockColors[$product->stock_status] ?? 'gray']
|
|
: ['muted' => __('commerce::commerce.products.not_tracked')];
|
|
|
|
// Build status toggles
|
|
$statusLines = [];
|
|
if ($product->is_active) {
|
|
$statusLines[] = ['badge' => __('commerce::commerce.status.active'), 'color' => 'green'];
|
|
} else {
|
|
$statusLines[] = ['badge' => __('commerce::commerce.status.inactive'), 'color' => 'gray'];
|
|
}
|
|
if ($product->is_featured) {
|
|
$statusLines[] = ['badge' => __('commerce::commerce.status.featured'), 'color' => 'amber'];
|
|
}
|
|
|
|
return [
|
|
[
|
|
'image' => $product->image_url,
|
|
'lines' => $productLines,
|
|
],
|
|
['mono' => $product->sku],
|
|
['lines' => $priceLines],
|
|
$stockCell,
|
|
['lines' => $statusLines],
|
|
[
|
|
'lines' => [
|
|
['muted' => $assignments->count().' '.__('commerce::commerce.products.entities')],
|
|
],
|
|
'actions' => [
|
|
['icon' => 'plus', 'click' => "openAssign({$product->id})", 'title' => __('commerce::commerce.actions.assign')],
|
|
],
|
|
],
|
|
[
|
|
'actions' => [
|
|
['icon' => 'pencil', 'click' => "openEdit({$product->id})", 'title' => __('commerce::commerce.actions.edit')],
|
|
['icon' => 'trash', 'click' => "delete({$product->id})", 'confirm' => __('commerce::commerce.products.actions.delete_confirm'), 'title' => __('commerce::commerce.actions.delete')],
|
|
],
|
|
],
|
|
];
|
|
})->all();
|
|
}
|
|
|
|
public function getProductsProperty()
|
|
{
|
|
$query = Product::with('ownerEntity');
|
|
|
|
// Filter by entity
|
|
if ($this->entityId) {
|
|
$entity = Entity::find($this->entityId);
|
|
if ($entity?->isM1()) {
|
|
$query->where('owner_entity_id', $this->entityId);
|
|
}
|
|
}
|
|
|
|
// Search
|
|
if ($this->search) {
|
|
$query->where(function ($q) {
|
|
$q->where('name', 'like', '%'.$this->search.'%')
|
|
->orWhere('sku', 'like', '%'.$this->search.'%');
|
|
});
|
|
}
|
|
|
|
// Category filter
|
|
if ($this->category) {
|
|
$query->where('category', $this->category);
|
|
}
|
|
|
|
// Stock filter
|
|
if ($this->stockFilter) {
|
|
$query->where('stock_status', $this->stockFilter);
|
|
}
|
|
|
|
return $query->orderBy('sort_order')->orderBy('name')->paginate(20);
|
|
}
|
|
|
|
public function openCreate(): void
|
|
{
|
|
if (! $this->entityId) {
|
|
$master = $this->masterEntities->first();
|
|
if ($master) {
|
|
$this->entityId = $master->id;
|
|
}
|
|
}
|
|
|
|
$this->resetForm();
|
|
$this->form['sku'] = Product::generateSku();
|
|
$this->showModal = true;
|
|
}
|
|
|
|
public function openEdit(int $productId): void
|
|
{
|
|
$product = Product::findOrFail($productId);
|
|
$this->editingId = $productId;
|
|
|
|
$this->form = [
|
|
'sku' => $product->sku,
|
|
'name' => $product->name,
|
|
'description' => $product->description ?? '',
|
|
'short_description' => $product->short_description ?? '',
|
|
'category' => $product->category ?? '',
|
|
'subcategory' => $product->subcategory ?? '',
|
|
'price' => $product->price,
|
|
'cost_price' => $product->cost_price,
|
|
'rrp' => $product->rrp,
|
|
'currency' => $product->currency,
|
|
'tax_class' => $product->tax_class,
|
|
'type' => $product->type,
|
|
'track_stock' => $product->track_stock,
|
|
'stock_quantity' => $product->stock_quantity,
|
|
'low_stock_threshold' => $product->low_stock_threshold,
|
|
'allow_backorder' => $product->allow_backorder,
|
|
'is_active' => $product->is_active,
|
|
'is_featured' => $product->is_featured,
|
|
'is_visible' => $product->is_visible,
|
|
];
|
|
|
|
$this->showModal = true;
|
|
}
|
|
|
|
public function save(): void
|
|
{
|
|
$this->validate([
|
|
'form.sku' => 'required|string|max:64',
|
|
'form.name' => 'required|string|max:255',
|
|
'form.price' => 'required|integer|min:0',
|
|
'form.type' => 'required|in:simple,variable,bundle,virtual,subscription',
|
|
]);
|
|
|
|
$entity = Entity::findOrFail($this->entityId);
|
|
|
|
if ($this->editingId) {
|
|
$product = Product::findOrFail($this->editingId);
|
|
$this->catalog->updateProduct($product, $this->form);
|
|
} else {
|
|
$this->catalog->createProduct($entity, $this->form);
|
|
}
|
|
|
|
$this->showModal = false;
|
|
$this->resetForm();
|
|
}
|
|
|
|
public function delete(int $productId): void
|
|
{
|
|
$product = Product::findOrFail($productId);
|
|
$this->catalog->deleteProduct($product);
|
|
}
|
|
|
|
public function toggleActive(int $productId): void
|
|
{
|
|
$product = Product::findOrFail($productId);
|
|
$product->update(['is_active' => ! $product->is_active]);
|
|
}
|
|
|
|
public function toggleFeatured(int $productId): void
|
|
{
|
|
$product = Product::findOrFail($productId);
|
|
$product->update(['is_featured' => ! $product->is_featured]);
|
|
}
|
|
|
|
// Assignment methods
|
|
|
|
public function openAssign(int $productId): void
|
|
{
|
|
$this->assignForm = [
|
|
'entity_id' => null,
|
|
'product_id' => $productId,
|
|
'price_override' => null,
|
|
'margin_percent' => null,
|
|
'name_override' => '',
|
|
'description_override' => '',
|
|
'is_active' => true,
|
|
'is_featured' => false,
|
|
];
|
|
$this->showAssignModal = true;
|
|
}
|
|
|
|
public function saveAssignment(): void
|
|
{
|
|
$this->validate([
|
|
'assignForm.entity_id' => 'required|exists:commerce_entities,id',
|
|
'assignForm.product_id' => 'required|exists:commerce_products,id',
|
|
]);
|
|
|
|
$entity = Entity::findOrFail($this->assignForm['entity_id']);
|
|
$product = Product::findOrFail($this->assignForm['product_id']);
|
|
|
|
$overrides = array_filter([
|
|
'price_override' => $this->assignForm['price_override'],
|
|
'margin_percent' => $this->assignForm['margin_percent'],
|
|
'name_override' => $this->assignForm['name_override'] ?: null,
|
|
'description_override' => $this->assignForm['description_override'] ?: null,
|
|
'is_active' => $this->assignForm['is_active'],
|
|
'is_featured' => $this->assignForm['is_featured'],
|
|
], fn ($v) => $v !== null && $v !== '');
|
|
|
|
$this->catalog->assignProduct($entity, $product, $overrides);
|
|
|
|
$this->showAssignModal = false;
|
|
}
|
|
|
|
public function removeAssignment(int $assignmentId): void
|
|
{
|
|
$assignment = ProductAssignment::findOrFail($assignmentId);
|
|
$this->catalog->removeAssignment($assignment);
|
|
}
|
|
|
|
public function getAssignmentsForProduct(int $productId)
|
|
{
|
|
return ProductAssignment::with('entity')
|
|
->where('product_id', $productId)
|
|
->get();
|
|
}
|
|
|
|
protected function resetForm(): void
|
|
{
|
|
$this->editingId = null;
|
|
$this->form = [
|
|
'sku' => '',
|
|
'name' => '',
|
|
'description' => '',
|
|
'short_description' => '',
|
|
'category' => '',
|
|
'subcategory' => '',
|
|
'price' => 0,
|
|
'cost_price' => null,
|
|
'rrp' => null,
|
|
'currency' => 'GBP',
|
|
'tax_class' => 'standard',
|
|
'type' => 'simple',
|
|
'track_stock' => true,
|
|
'stock_quantity' => 0,
|
|
'low_stock_threshold' => 5,
|
|
'allow_backorder' => false,
|
|
'is_active' => true,
|
|
'is_featured' => false,
|
|
'is_visible' => true,
|
|
];
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
return view('commerce::admin.product-manager')
|
|
->layout('hub::admin.layouts.app', ['title' => 'Product Catalog']);
|
|
}
|
|
}
|