From 448ac63260383f8354009b9cd544e50680bec197 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 10 Jun 2026 02:29:46 +0700 Subject: [PATCH] Add product management features including Product, ProductVariant, and ProductPrice models. Implement ProductController for CRUD operations, integrate permissions in Role and Permission enums, and enhance UI components for product listing and management. Introduce new migrations for products and related entities, and update sidebar for product navigation. --- app/Enums/Permission.php | 14 + app/Enums/PriceType.php | 39 ++ app/Enums/Role.php | 16 + .../Controllers/Admin/ProductController.php | 338 +++++++++++++++ app/Http/Requests/Admin/ProductRequest.php | 46 ++ app/Models/Category.php | 6 + app/Models/Product.php | 34 ++ app/Models/ProductPrice.php | 25 ++ app/Models/ProductVariant.php | 32 ++ ...026_06_10_100001_create_products_table.php | 26 ++ ...100002_create_product_categories_table.php | 23 + ...0_100003_create_product_variants_table.php | 27 ++ ..._10_100004_create_product_prices_table.php | 27 ++ resources/js/components/AppSidebar.vue | 16 +- .../js/components/data-table/DataTable.vue | 66 ++- .../js/components/products/ProductForm.vue | 394 ++++++++++++++++++ .../products/ProductGroupedTable.vue | 221 ++++++++++ resources/js/components/products/columns.ts | 128 ++++++ .../products/data-table-actions.vue | 79 ++++ .../products/product-status-toggle.vue | 61 +++ .../js/components/ui/table/TableCell.vue | 2 + resources/js/lib/products.ts | 28 ++ resources/js/pages/admin/products/Create.vue | 42 ++ resources/js/pages/admin/products/Edit.vue | 52 +++ resources/js/pages/admin/products/Index.vue | 111 +++++ resources/js/types/product.ts | 122 ++++++ resources/js/types/tanstack-table.d.ts | 9 + routes/web.php | 65 ++- 28 files changed, 2018 insertions(+), 31 deletions(-) create mode 100644 app/Enums/PriceType.php create mode 100644 app/Http/Controllers/Admin/ProductController.php create mode 100644 app/Http/Requests/Admin/ProductRequest.php create mode 100644 app/Models/Product.php create mode 100644 app/Models/ProductPrice.php create mode 100644 app/Models/ProductVariant.php create mode 100644 database/migrations/2026_06_10_100001_create_products_table.php create mode 100644 database/migrations/2026_06_10_100002_create_product_categories_table.php create mode 100644 database/migrations/2026_06_10_100003_create_product_variants_table.php create mode 100644 database/migrations/2026_06_10_100004_create_product_prices_table.php create mode 100644 resources/js/components/products/ProductForm.vue create mode 100644 resources/js/components/products/ProductGroupedTable.vue create mode 100644 resources/js/components/products/columns.ts create mode 100644 resources/js/components/products/data-table-actions.vue create mode 100644 resources/js/components/products/product-status-toggle.vue create mode 100644 resources/js/lib/products.ts create mode 100644 resources/js/pages/admin/products/Create.vue create mode 100644 resources/js/pages/admin/products/Edit.vue create mode 100644 resources/js/pages/admin/products/Index.vue create mode 100644 resources/js/types/product.ts create mode 100644 resources/js/types/tanstack-table.d.ts diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index c8a14ef..c97e998 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -22,6 +22,12 @@ enum Permission: string case CATEGORIES_UPDATE = 'categories.update'; case CATEGORIES_DELETE = 'categories.delete'; + case PRODUCTS_VIEW = 'products.view'; + case PRODUCTS_CREATE = 'products.create'; + case PRODUCTS_UPDATE = 'products.update'; + case PRODUCTS_DELETE = 'products.delete'; + case PRODUCTS_TOGGLE_STATUS = 'products.toggle-status'; + public function label(): string { return match ($this) { @@ -38,6 +44,12 @@ public function label(): string self::CATEGORIES_CREATE => 'Tambah Kategori', self::CATEGORIES_UPDATE => 'Ubah Kategori', self::CATEGORIES_DELETE => 'Hapus Kategori', + + self::PRODUCTS_VIEW => 'Lihat Produk', + self::PRODUCTS_CREATE => 'Tambah Produk', + self::PRODUCTS_UPDATE => 'Ubah Produk', + self::PRODUCTS_DELETE => 'Hapus Produk', + self::PRODUCTS_TOGGLE_STATUS => 'Ubah Status Produk', }; } @@ -49,6 +61,8 @@ public function group(): string self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai', self::CATEGORIES_VIEW, self::CATEGORIES_CREATE, self::CATEGORIES_UPDATE, self::CATEGORIES_DELETE => 'Kategori', + self::PRODUCTS_VIEW, self::PRODUCTS_CREATE, self::PRODUCTS_UPDATE, + self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk', }; } diff --git a/app/Enums/PriceType.php b/app/Enums/PriceType.php new file mode 100644 index 0000000..b8b98ed --- /dev/null +++ b/app/Enums/PriceType.php @@ -0,0 +1,39 @@ + 'Distributor', + self::AGENT => 'Agen', + self::SUB_AGENT => 'Sub Agen', + self::GROSIR => 'Grosir', + self::ECER => 'Eceran', + self::TIKTOK => 'TikTok', + self::SHOPEE => 'Shopee', + }; + } + + /** + * @return list + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/Role.php b/app/Enums/Role.php index 2ef08ab..caa9a97 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -48,6 +48,11 @@ public function permissions(): array Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, Permission::CATEGORIES_DELETE, + Permission::PRODUCTS_VIEW, + Permission::PRODUCTS_CREATE, + Permission::PRODUCTS_UPDATE, + Permission::PRODUCTS_DELETE, + Permission::PRODUCTS_TOGGLE_STATUS, ], self::ADMIN_TOKO => [ Permission::DASHBOARD_VIEW, @@ -60,6 +65,11 @@ public function permissions(): array Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, Permission::CATEGORIES_DELETE, + Permission::PRODUCTS_VIEW, + Permission::PRODUCTS_CREATE, + Permission::PRODUCTS_UPDATE, + Permission::PRODUCTS_DELETE, + Permission::PRODUCTS_TOGGLE_STATUS, ], self::ADMIN_BAHAN_BAKU => [ Permission::DASHBOARD_VIEW, @@ -68,11 +78,17 @@ public function permissions(): array Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, Permission::CATEGORIES_DELETE, + Permission::PRODUCTS_VIEW, + Permission::PRODUCTS_CREATE, + Permission::PRODUCTS_UPDATE, + Permission::PRODUCTS_DELETE, + Permission::PRODUCTS_TOGGLE_STATUS, ], self::MARKETING => [ Permission::DASHBOARD_VIEW, Permission::EMPLOYEES_VIEW, Permission::CATEGORIES_VIEW, + Permission::PRODUCTS_VIEW, ], self::NON_OPERATOR => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php new file mode 100644 index 0000000..a8c1537 --- /dev/null +++ b/app/Http/Controllers/Admin/ProductController.php @@ -0,0 +1,338 @@ +parseDataTableQuery($request); + $search = $tableQuery['search']; + $sort = $tableQuery['sort']; + $direction = $tableQuery['direction']; + $isActive = $request->string('is_active')->toString(); + + $query = Product::query() + ->with([ + 'categories', + 'variants' => fn ($query) => $query + ->with(['prices' => fn ($query) => $query->orderBy('type')]) + ->orderBy('created_at'), + ]) + ->when($search !== '', function (Builder $query) use ($search): void { + $query->where(function (Builder $query) use ($search): void { + $query->where('name', 'like', "%{$search}%") + ->orWhere('slug', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%") + ->orWhereHas('categories', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")) + ->orWhereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); + }); + }) + ->when( + $isActive !== '', + fn (Builder $query) => $query->where('is_active', $isActive === '1') + ); + + $this->applySorting($query, $sort, $direction); + + $products = $query + ->paginate(10) + ->withQueryString(); + + $rows = $products->getCollection() + ->flatMap(fn (Product $product, int $index) => $this->flattenProductForTable($product, $index, $products->firstItem() ?? 1)) + ->values() + ->all(); + + return Inertia::render('admin/products/Index', [ + 'rows' => $rows, + 'pagination' => [ + 'current_page' => $products->currentPage(), + 'last_page' => $products->lastPage(), + 'per_page' => $products->perPage(), + 'total' => $products->total(), + 'links' => $products->linkCollection()->toArray(), + ], + 'filters' => $this->dataTableFilters($tableQuery, [ + 'is_active' => $isActive, + ]), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/products/Create', [ + 'categories' => $this->categoryOptions(), + 'priceTypes' => PriceType::selectOptions(), + ]); + } + + public function store(ProductRequest $request): RedirectResponse + { + $validated = $request->validated(); + + DB::transaction(function () use ($validated): void { + $product = Product::create([ + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + 'is_active' => true, + ]); + + $product->categories()->sync($validated['category_ids']); + + foreach ($validated['variants'] as $variantData) { + $this->createVariant($product, $variantData); + } + }); + + Inertia::flash('success', 'Produk berhasil ditambahkan.'); + + return redirect()->route('admin.master.products.index'); + } + + public function edit(Product $product): Response + { + $product->load([ + 'categories', + 'variants' => fn ($query) => $query + ->with(['prices' => fn ($query) => $query->orderBy('type')]) + ->orderBy('created_at'), + ]); + + return Inertia::render('admin/products/Edit', [ + 'categories' => $this->categoryOptions(), + 'priceTypes' => PriceType::selectOptions(), + 'product' => $this->transformProductForForm($product), + ]); + } + + public function update(ProductRequest $request, Product $product): RedirectResponse + { + $validated = $request->validated(); + + DB::transaction(function () use ($validated, $product): void { + $product->name = $validated['name']; + $product->description = $validated['description'] ?? null; + $product->save(); + + $product->categories()->sync($validated['category_ids']); + + $submittedVariantIds = collect($validated['variants']) + ->pluck('id') + ->filter() + ->map(fn ($id) => (int) $id) + ->all(); + + $product->variants() + ->whereNotIn('id', $submittedVariantIds) + ->get() + ->each(fn (ProductVariant $variant) => $variant->delete()); + + foreach ($validated['variants'] as $variantData) { + if (! empty($variantData['id'])) { + $variant = $product->variants()->findOrFail($variantData['id']); + $variant->name = $variantData['name']; + $variant->stock = $variantData['stock']; + $variant->save(); + $this->syncVariantPrices($variant, $variantData['prices']); + + continue; + } + + $this->createVariant($product, $variantData); + } + }); + + Inertia::flash('success', 'Produk berhasil diperbarui.'); + + return redirect()->route('admin.master.products.index'); + } + + public function toggleStatus(Request $request, Product $product): RedirectResponse + { + $validated = $request->validate([ + 'is_active' => ['required', 'boolean'], + ]); + + $product->is_active = $validated['is_active']; + $product->save(); + + Inertia::flash('success', 'Status produk berhasil diperbarui.'); + + return back(); + } + + public function destroy(Product $product): RedirectResponse + { + DB::transaction(function () use ($product): void { + $product->variants()->delete(); + $product->categories()->detach(); + $product->delete(); + }); + + Inertia::flash('success', 'Produk berhasil dihapus.'); + + return redirect()->route('admin.master.products.index'); + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['name', 'slug', 'is_active'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } + + /** + * @return list + */ + private function categoryOptions(): array + { + return Category::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (Category $category) => [ + 'value' => $category->id, + 'label' => $category->name, + ]) + ->all(); + } + + /** + * @param array $variantData + */ + private function createVariant(Product $product, array $variantData): ProductVariant + { + $variant = $product->variants()->create([ + 'name' => $variantData['name'], + 'stock' => $variantData['stock'], + ]); + + $this->syncVariantPrices($variant, $variantData['prices']); + + return $variant; + } + + /** + * @param list $prices + */ + private function syncVariantPrices(ProductVariant $variant, array $prices): void + { + foreach ($prices as $priceData) { + ProductPrice::updateOrCreate( + [ + 'variant_id' => $variant->id, + 'type' => $priceData['type'], + ], + [ + 'price' => $priceData['price'], + ], + ); + } + } + + /** + * @return list> + */ + private function flattenProductForTable(Product $product, int $productIndex, int $firstItem): array + { + $variants = $product->variants->isNotEmpty() + ? $product->variants + : collect([null]); + + $variantCount = $variants->count(); + + return $variants + ->values() + ->map(function ($variant, int $variantIndex) use ($product, $productIndex, $firstItem, $variantCount) { + return [ + 'row_id' => $variant + ? "{$product->id}-{$variant->id}" + : "{$product->id}-empty", + 'product_id' => $product->id, + 'product_name' => $product->name, + 'categories' => $product->categories + ->map(fn (Category $category) => [ + 'id' => $category->id, + 'name' => $category->name, + ]) + ->values() + ->all(), + 'is_active' => $product->is_active, + 'is_first_variant' => $variantIndex === 0, + 'variant_count' => $variantCount, + 'product_row_number' => $firstItem + $productIndex, + 'variant_id' => $variant?->id, + 'variant_name' => $variant?->name, + 'stock' => $variant?->stock, + 'prices' => $variant + ? $variant->prices + ->map(fn (ProductPrice $price) => [ + 'type' => $price->type->value, + 'type_label' => $price->type->label(), + 'price' => $price->price, + 'price_formatted' => $this->formatPrice($price->price), + ]) + ->values() + ->all() + : [], + ]; + }) + ->all(); + } + + /** + * @return array + */ + private function transformProductForForm(Product $product): array + { + return [ + 'id' => $product->id, + 'name' => $product->name, + 'description' => $product->description, + 'category_ids' => $product->categories->pluck('id')->all(), + 'variants' => $product->variants + ->map(fn (ProductVariant $variant) => [ + 'id' => $variant->id, + 'name' => $variant->name, + 'stock' => $variant->stock, + 'prices' => collect(PriceType::cases()) + ->mapWithKeys(function (PriceType $type) use ($variant) { + $price = $variant->prices->firstWhere('type', $type); + + return [ + $type->value => $price ? (string) (int) $price->price : '', + ]; + }) + ->all(), + ]) + ->values() + ->all(), + ]; + } + + private function formatPrice(float|string $price): string + { + return 'Rp '.number_format((float) $price, 0, ',', '.'); + } +} diff --git a/app/Http/Requests/Admin/ProductRequest.php b/app/Http/Requests/Admin/ProductRequest.php new file mode 100644 index 0000000..98e74c6 --- /dev/null +++ b/app/Http/Requests/Admin/ProductRequest.php @@ -0,0 +1,46 @@ +isMethod('POST') + ? Permission::PRODUCTS_CREATE + : Permission::PRODUCTS_UPDATE; + + return $this->user()?->can($permission->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:200'], + 'description' => ['nullable', 'string'], + 'category_ids' => ['required', 'array', 'min:1'], + 'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')], + 'variants' => ['required', 'array', 'min:1'], + 'variants.*.id' => [ + 'nullable', + 'integer', + Rule::exists('product_variants', 'id') + ->where('product_id', $this->route('product')?->id) + ->whereNull('deleted_at'), + ], + 'variants.*.name' => ['required', 'string', 'max:200'], + 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.prices' => ['required', 'array', 'min:1'], + 'variants.*.prices.*.type' => ['required', Rule::enum(PriceType::class)], + 'variants.*.prices.*.price' => ['required', 'numeric', 'gt:0'], + ]; + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php index 3e6bba4..56cae0e 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Sluggable\Attributes\Sluggable; @@ -12,4 +13,9 @@ class Category extends Model { use SoftDeletes; + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'product_categories'); + } } diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..edca004 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,34 @@ + 'boolean', + ]; + } + + public function categories(): BelongsToMany + { + return $this->belongsToMany(Category::class, 'product_categories'); + } + + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class); + } +} diff --git a/app/Models/ProductPrice.php b/app/Models/ProductPrice.php new file mode 100644 index 0000000..549b561 --- /dev/null +++ b/app/Models/ProductPrice.php @@ -0,0 +1,25 @@ + PriceType::class, + 'price' => 'decimal:2', + ]; + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 0000000..86fa4e3 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,32 @@ + 'integer', + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function prices(): HasMany + { + return $this->hasMany(ProductPrice::class, 'variant_id'); + } +} diff --git a/database/migrations/2026_06_10_100001_create_products_table.php b/database/migrations/2026_06_10_100001_create_products_table.php new file mode 100644 index 0000000..f6b427f --- /dev/null +++ b/database/migrations/2026_06_10_100001_create_products_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('name', 200); + $table->string('slug', 200)->unique(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_06_10_100002_create_product_categories_table.php b/database/migrations/2026_06_10_100002_create_product_categories_table.php new file mode 100644 index 0000000..9b6d42d --- /dev/null +++ b/database/migrations/2026_06_10_100002_create_product_categories_table.php @@ -0,0 +1,23 @@ +foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('category_id')->constrained()->cascadeOnDelete(); + + $table->primary(['product_id', 'category_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_categories'); + } +}; diff --git a/database/migrations/2026_06_10_100003_create_product_variants_table.php b/database/migrations/2026_06_10_100003_create_product_variants_table.php new file mode 100644 index 0000000..f8476de --- /dev/null +++ b/database/migrations/2026_06_10_100003_create_product_variants_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('name', 200); + $table->unsignedInteger('stock')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index('product_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_06_10_100004_create_product_prices_table.php b/database/migrations/2026_06_10_100004_create_product_prices_table.php new file mode 100644 index 0000000..4f69f5b --- /dev/null +++ b/database/migrations/2026_06_10_100004_create_product_prices_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->string('type', 20); + $table->decimal('price', 18, 2); + $table->timestamps(); + + $table->unique(['variant_id', 'type']); + $table->index('variant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_prices'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index cee0667..6b4e060 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@