bianjunhui 3 дней назад
Родитель
Сommit
5fe05e50eb

+ 91 - 0
packages/Webkul/Admin/src/Http/Controllers/Catalog/CategoryController.php

@@ -301,4 +301,95 @@ class CategoryController extends Controller
 
 
         return response()->json($categories);
         return response()->json($categories);
     }
     }
+
+    /**
+     * Get products of a category (including descendant categories' products).
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function products(int $id)
+    {
+        $data = $this->categoryRepository->getCategoryProducts($id);
+
+        return response()->json([
+            'products' => $data['products']->map(function ($product) {
+                return [
+                    'id'              => $product->id,
+                    'sku'             => $product->sku,
+                    'name'            => $product->name,
+                    'position'        => $product->pivot_position,
+                    'base_image_url'  => $product->base_image_url ?? null,
+                ];
+            }),
+            'children_products' => $data['children_products']->map(function ($product) {
+                return [
+                    'id'             => $product->id,
+                    'sku'            => $product->sku,
+                    'name'           => $product->name,
+                    'category_id'    => $product->category_id,
+                    'category_name'  => $product->category_name,
+                    'base_image_url' => $product->base_image_url ?? null,
+                ];
+            }),
+        ]);
+    }
+
+    /**
+     * Attach a product to a category.
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function attachProduct(int $id)
+    {
+        $this->categoryRepository->attachProduct($id, (int) request()->input('product_id'));
+
+        return response()->json([
+            'message' => trans('admin::app.catalog.categories.product-attach-success'),
+        ]);
+    }
+
+    /**
+     * Detach a product from a category.
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function detachProduct(int $id)
+    {
+        $this->categoryRepository->detachProduct($id, (int) request()->input('product_id'));
+
+        return response()->json([
+            'message' => trans('admin::app.catalog.categories.product-detach-success'),
+        ]);
+    }
+
+    /**
+     * Update product(s) sort position within a category.
+     *
+     * Supports a single product (product_id + position) or a bulk reorder
+     * (products => [{id, position}, ...]) after a drag-and-drop.
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function updateProductPosition(int $id)
+    {
+        if ($products = request()->input('products')) {
+            foreach ($products as $item) {
+                $this->categoryRepository->updateProductPosition(
+                    $id,
+                    (int) $item['id'],
+                    (int) $item['position']
+                );
+            }
+        } else {
+            $this->categoryRepository->updateProductPosition(
+                $id,
+                (int) request()->input('product_id'),
+                (int) request()->input('position')
+            );
+        }
+
+        return response()->json([
+            'message' => trans('admin::app.catalog.categories.product-position-updated'),
+        ]);
+    }
 }
 }

+ 19 - 6
packages/Webkul/Admin/src/Resources/lang/en/app.php

@@ -1690,14 +1690,27 @@ return [
                 'slug'                     => 'Slug',
                 'slug'                     => 'Slug',
                 'title'                    => 'Edit Category',
                 'title'                    => 'Edit Category',
                 'visible-in-menu'          => 'Visible In Menu',
                 'visible-in-menu'          => 'Visible In Menu',
+                'products'                 => 'Products',
+                'add-product'              => 'Add Product',
+                'search-products'          => 'Search Products',
+                'product-name'             => 'Product Name',
+                'product-sku'              => 'SKU',
+                'product-position'         => 'Sort Order',
+                'no-products'              => 'No products in this category.',
+                'child-category-products'  => 'Sub-category Products',
+                'remove-product'           => 'Remove',
+                'position-hint'            => 'Higher value appears first.',
             ],
             ],
 
 
-            'category'             => 'Category',
-            'create-success'       => 'Category created successfully.',
-            'delete-category-root' => 'The Root category can not be deleted.',
-            'delete-failed'        => 'Error encountered while deleting category',
-            'delete-success'       => 'The category has been successfully deleted.',
-            'update-success'       => 'Category updated successfully.',
+            'category'                 => 'Category',
+            'create-success'           => 'Category created successfully.',
+            'delete-category-root'     => 'The Root category can not be deleted.',
+            'delete-failed'            => 'Error encountered while deleting category',
+            'delete-success'           => 'The category has been successfully deleted.',
+            'update-success'           => 'Category updated successfully.',
+            'product-attach-success'   => 'Product added to category successfully.',
+            'product-detach-success'   => 'Product removed from category successfully.',
+            'product-position-updated' => 'Product sort order updated successfully.',
         ],
         ],
 
 
         'families' => [
         'families' => [

+ 2 - 0
packages/Webkul/Admin/src/Resources/views/catalog/categories/edit.blade.php

@@ -316,6 +316,8 @@
                 </div>
                 </div>
 
 
                 {!! view_render_event('bagisto.admin.catalog.categories.edit.card.seo.after', ['category' => $category]) !!}
                 {!! view_render_event('bagisto.admin.catalog.categories.edit.card.seo.after', ['category' => $category]) !!}
+
+                @include('admin::catalog.categories.products')
             </div>
             </div>
 
 
             <!-- Right Section -->
             <!-- Right Section -->

+ 343 - 0
packages/Webkul/Admin/src/Resources/views/catalog/categories/products.blade.php

@@ -0,0 +1,343 @@
+{!! view_render_event('bagisto.admin.catalog.categories.edit.card.products.before', ['category' => $category]) !!}
+
+<!-- Products -->
+<div class="box-shadow rounded bg-white p-4 dark:bg-gray-900">
+    <p class="mb-4 flex items-center justify-between text-base font-semibold text-gray-800 dark:text-white">
+        @lang('admin::app.catalog.categories.edit.products')
+    </p>
+
+    <v-category-products>
+        <x-admin::shimmer.datagrid />
+    </v-category-products>
+</div>
+
+{!! view_render_event('bagisto.admin.catalog.categories.edit.card.products.after', ['category' => $category]) !!}
+
+@pushOnce('scripts')
+    <script
+        type="text/x-template"
+        id="v-category-products-template"
+    >
+        <div>
+            <!-- Header actions -->
+            <div class="mb-4 flex justify-end">
+                <button
+                    type="button"
+                    class="secondary-button"
+                    @click="openModal"
+                >
+                    @lang('admin::app.catalog.categories.edit.add-product')
+                </button>
+            </div>
+
+            <template v-if="isLoading">
+                <x-admin::shimmer.datagrid />
+            </template>
+
+            <template v-else>
+                <!-- Current category products (sortable) -->
+                <div v-if="products.length" class="mb-6">
+                    <draggable
+                        ghost-class="draggable-ghost"
+                        v-bind="{ animation: 200 }"
+                        handle=".icon-drag"
+                        :list="products"
+                        item-key="id"
+                        @end="onDragEnd"
+                    >
+                        <template #item="{ element }">
+                            <div class="flex items-center justify-between gap-3 border-b border-slate-200 py-3 dark:border-gray-800">
+                                <div class="flex items-center gap-3">
+                                    <span class="icon-drag cursor-move text-xl text-gray-400"></span>
+
+                                    <img
+                                        v-if="element.base_image_url"
+                                        :src="element.base_image_url"
+                                        class="h-10 w-10 rounded object-cover"
+                                    />
+
+                                    <div>
+                                        <p class="text-sm font-medium text-gray-800 dark:text-white" v-text="element.name"></p>
+
+                                        <p class="text-xs text-gray-500 dark:text-gray-400" v-text="element.sku"></p>
+                                    </div>
+                                </div>
+
+                                <div class="flex items-center gap-3">
+                                    <input
+                                        type="number"
+                                        :value="element.position"
+                                        class="w-20 rounded-md border px-2 py-1 text-sm text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300"
+                                        @change="updatePosition(element, $event)"
+                                    />
+
+                                    <button
+                                        type="button"
+                                        class="transparent-button !p-1.5 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
+                                        @click="removeProduct(element.id)"
+                                    >
+                                        <span class="icon-delete text-xl"></span>
+                                    </button>
+                                </div>
+                            </div>
+                        </template>
+                    </draggable>
+                </div>
+
+                <div
+                    v-else
+                    class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
+                >
+                    @lang('admin::app.catalog.categories.edit.no-products')
+                </div>
+
+                <!-- Sub-category products (read-only) -->
+                <div v-if="childrenProducts.length">
+                    <p class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
+                        @lang('admin::app.catalog.categories.edit.child-category-products')
+                    </p>
+
+                    <div
+                        v-for="product in childrenProducts"
+                        :key="'child-' + product.id"
+                        class="flex items-center gap-3 border-b border-slate-200 py-3 dark:border-gray-800"
+                    >
+                        <img
+                            v-if="product.base_image_url"
+                            :src="product.base_image_url"
+                            class="h-10 w-10 rounded object-cover"
+                        />
+
+                        <div>
+                            <p class="text-sm font-medium text-gray-800 dark:text-white" v-text="product.name"></p>
+
+                            <p class="text-xs text-gray-500 dark:text-gray-400">
+                                <span v-text="product.sku"></span>
+                                <span class="mx-1">·</span>
+                                <span class="text-gray-400" v-text="product.category_name"></span>
+                            </p>
+                        </div>
+                    </div>
+                </div>
+            </template>
+
+            <!-- Add Product Modal -->
+            <x-admin::modal ref="addProductModal">
+                <x-slot:header>
+                    <p class="text-lg font-bold text-gray-800 dark:text-white">
+                        @lang('admin::app.catalog.categories.edit.add-product')
+                    </p>
+                </x-slot>
+
+                <x-slot:content>
+                    <div class="mb-4 flex gap-2">
+                        <input
+                            type="text"
+                            v-model="searchQuery"
+                            class="flex min-h-[39px] w-full rounded-md border px-3 py-2 text-sm text-gray-600 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300"
+                            placeholder="@lang('admin::app.catalog.categories.edit.search-products')"
+                            @keyup.enter="searchProducts"
+                        />
+
+                        <button
+                            type="button"
+                            class="secondary-button"
+                            @click="searchProducts"
+                        >
+                            @lang('admin::app.catalog.categories.edit.search-products')
+                        </button>
+                    </div>
+
+                    <div class="max-h-[400px] overflow-y-auto">
+                        <div
+                            v-for="product in searchResults"
+                            :key="'search-' + product.id"
+                            class="flex items-center justify-between border-b border-slate-200 py-2.5 last:border-b-0 dark:border-gray-800"
+                        >
+                            <div>
+                                <p class="text-sm font-medium text-gray-800 dark:text-white" v-text="product.name"></p>
+
+                                <p class="text-xs text-gray-500 dark:text-gray-400" v-text="product.sku"></p>
+                            </div>
+
+                            <button
+                                type="button"
+                                class="primary-button !px-3 !py-1.5"
+                                @click="attachProduct(product.id)"
+                            >
+                                @lang('admin::app.catalog.categories.edit.add-product')
+                            </button>
+                        </div>
+
+                        <div
+                            v-if="searched && searchResults.length === 0"
+                            class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
+                        >
+                            @lang('admin::app.catalog.categories.edit.no-products')
+                        </div>
+                    </div>
+                </x-slot>
+            </x-admin::modal>
+        </div>
+    </script>
+
+    <script type="module">
+        app.component('v-category-products', {
+            template: '#v-category-products-template',
+
+            data() {
+                return {
+                    isLoading: true,
+
+                    products: [],
+
+                    childrenProducts: [],
+
+                    searchQuery: '',
+
+                    searchResults: [],
+
+                    searched: false,
+                };
+            },
+
+            mounted() {
+                this.get();
+            },
+
+            methods: {
+                get() {
+                    this.isLoading = true;
+
+                    axios.get("{{ route('admin.catalog.categories.products', $category->id) }}")
+                        .then(response => {
+                            this.isLoading = false;
+
+                            this.products = response.data.products;
+
+                            this.childrenProducts = response.data.children_products;
+                        })
+                        .catch(error => {
+                            this.isLoading = false;
+
+                            console.log(error);
+                        });
+                },
+
+                openModal() {
+                    this.searchQuery = '';
+
+                    this.searchResults = [];
+
+                    this.searched = false;
+
+                    this.$refs.addProductModal.open();
+                },
+
+                searchProducts() {
+                    if (! this.searchQuery.trim()) {
+                        return;
+                    }
+
+                    this.searched = true;
+
+                    axios.get("{{ route('admin.catalog.products.search') }}", {
+                        params: {
+                            query: this.searchQuery,
+                        },
+                    })
+                        .then(response => {
+                            this.searchResults = response.data.data ?? [];
+                        })
+                        .catch(error => {
+                            console.log(error);
+                        });
+                },
+
+                attachProduct(productId) {
+                    axios.post("{{ route('admin.catalog.categories.products.attach', $category->id) }}", {
+                        product_id: productId,
+                    })
+                        .then(response => {
+                            this.$emitter.emit('add-flash', {
+                                type: 'success',
+                                message: response.data.message,
+                            });
+
+                            this.$refs.addProductModal.close();
+
+                            this.get();
+                        })
+                        .catch(error => {
+                            console.log(error);
+                        });
+                },
+
+                removeProduct(productId) {
+                    axios.post("{{ route('admin.catalog.categories.products.detach', $category->id) }}", {
+                        product_id: productId,
+                    })
+                        .then(response => {
+                            this.$emitter.emit('add-flash', {
+                                type: 'success',
+                                message: response.data.message,
+                            });
+
+                            this.get();
+                        })
+                        .catch(error => {
+                            console.log(error);
+                        });
+                },
+
+                onDragEnd() {
+                    const payload = {
+                        products: this.products.map((product, index) => ({
+                            id: product.id,
+                            position: this.products.length - index,
+                        })),
+                    };
+
+                    this.savePositions(payload);
+                },
+
+                savePositions(payload) {
+                    axios.post("{{ route('admin.catalog.categories.products.position', $category->id) }}", payload)
+                        .then(response => {
+                            this.$emitter.emit('add-flash', {
+                                type: 'success',
+                                message: response.data.message,
+                            });
+                        })
+                        .catch(error => {
+                            console.log(error);
+                        });
+                },
+
+                updatePosition(product, event) {
+                    const position = parseInt(event.target.value, 10);
+
+                    if (Number.isNaN(position)) {
+                        return;
+                    }
+
+                    product.position = position;
+
+                    axios.post("{{ route('admin.catalog.categories.products.position', $category->id) }}", {
+                        product_id: product.id,
+                        position: position,
+                    })
+                        .then(response => {
+                            this.$emitter.emit('add-flash', {
+                                type: 'success',
+                                message: response.data.message,
+                            });
+                        })
+                        .catch(error => {
+                            console.log(error);
+                        });
+                },
+            },
+        });
+    </script>
+@endPushOnce

+ 8 - 0
packages/Webkul/Admin/src/Routes/catalog-routes.php

@@ -78,6 +78,14 @@ Route::prefix('catalog')->group(function () {
         Route::get('search', 'search')->name('admin.catalog.categories.search');
         Route::get('search', 'search')->name('admin.catalog.categories.search');
 
 
         Route::get('tree', 'tree')->name('admin.catalog.categories.tree');
         Route::get('tree', 'tree')->name('admin.catalog.categories.tree');
+
+        Route::get('products/{id}', 'products')->name('admin.catalog.categories.products');
+
+        Route::post('products/{id}/attach', 'attachProduct')->name('admin.catalog.categories.products.attach');
+
+        Route::post('products/{id}/detach', 'detachProduct')->name('admin.catalog.categories.products.detach');
+
+        Route::post('products/{id}/position', 'updateProductPosition')->name('admin.catalog.categories.products.position');
     });
     });
 
 
     /**
     /**

+ 28 - 0
packages/Webkul/Category/src/Database/Migrations/2026_08_28_000001_add_position_to_product_categories_table.php

@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('product_categories', function (Blueprint $table) {
+            $table->integer('position')->default(0)->after('category_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('product_categories', function (Blueprint $table) {
+            $table->dropColumn('position');
+        });
+    }
+};

+ 3 - 1
packages/Webkul/Category/src/Models/Category.php

@@ -64,7 +64,9 @@ class Category extends TranslatableModel implements CategoryContract
      */
      */
     public function products(): BelongsToMany
     public function products(): BelongsToMany
     {
     {
-        return $this->belongsToMany(ProductProxy::modelClass(), 'product_categories');
+        return $this->belongsToMany(ProductProxy::modelClass(), 'product_categories')
+            ->withPivot('position')
+            ->orderByPivot('position', 'desc');
     }
     }
 
 
     /**
     /**

+ 85 - 0
packages/Webkul/Category/src/Repositories/CategoryRepository.php

@@ -292,6 +292,91 @@ class CategoryRepository extends Repository
         return $trimmed;
         return $trimmed;
     }
     }
 
 
+    /**
+     * Get products of a category (including descendant categories) with their
+     * sort position.
+     *
+     * Direct products are returned first (ordered by position desc), followed by
+     * descendant categories' products (read-only, grouped by category).
+     *
+     * @param  int  $categoryId
+     * @return array{products: \Illuminate\Support\Collection, children_products: \Illuminate\Support\Collection}
+     */
+    public function getCategoryProducts(int $categoryId): array
+    {
+        $category = $this->findOrFail($categoryId);
+
+        $products = $category->products()
+            ->with(['images', 'attribute_values', 'attribute_family'])
+            ->get()
+            ->map(function ($product) {
+                $product->pivot_position = (int) $product->pivot->position;
+
+                return $product;
+            });
+
+        $childrenProducts = collect();
+
+        foreach ($category->children as $child) {
+            $childProducts = $child->products()
+                ->with(['images', 'attribute_values', 'attribute_family'])
+                ->get()
+                ->map(function ($product) use ($child) {
+                    $product->category_name = $child->name;
+                    $product->category_id = $child->id;
+
+                    return $product;
+                });
+
+            $childrenProducts = $childrenProducts->concat($childProducts);
+        }
+
+        return [
+            'products'         => $products,
+            'children_products' => $childrenProducts,
+        ];
+    }
+
+    /**
+     * Attach a product to a category.
+     *
+     * @return void
+     */
+    public function attachProduct(int $categoryId, int $productId): void
+    {
+        $category = $this->findOrFail($categoryId);
+
+        if (! $category->products()->where('product_id', $productId)->exists()) {
+            $maxPosition = (int) $category->products()->max('position');
+
+            $category->products()->attach($productId, ['position' => $maxPosition + 1]);
+        }
+    }
+
+    /**
+     * Detach a product from a category.
+     *
+     * @return void
+     */
+    public function detachProduct(int $categoryId, int $productId): void
+    {
+        $category = $this->findOrFail($categoryId);
+
+        $category->products()->detach($productId);
+    }
+
+    /**
+     * Update a product's sort position within a category.
+     *
+     * @return void
+     */
+    public function updateProductPosition(int $categoryId, int $productId, int $position): void
+    {
+        $category = $this->findOrFail($categoryId);
+
+        $category->products()->updateExistingPivot($productId, ['position' => $position]);
+    }
+
     /**
     /**
      * Set same value to all locales in category.
      * Set same value to all locales in category.
      *
      *