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 @@