diff --git a/app/Enums/Concentration.php b/app/Enums/Concentration.php
new file mode 100644
index 0000000..a030299
--- /dev/null
+++ b/app/Enums/Concentration.php
@@ -0,0 +1,36 @@
+ 'Extrait de parfum',
+ self::EAU_DE_PERFUME => 'Eau de parfum',
+ self::EAU_DE_COLOGNE => 'Eau de Cologne',
+ self::EAU_DE_TOILETTE => 'Eau de toilette',
+ };
+ }
+
+ public function color()
+ {
+ return match ($this) {
+ self::EXTRAIT_DE_PERFUME => 'emerald',
+ self::EAU_DE_PERFUME => 'rose',
+ self::EAU_DE_COLOGNE => 'cyan',
+ self::EAU_DE_TOILETTE => 'indigo',
+ };
+ }
+}
diff --git a/app/Livewire/Datatable/PerfumesTable.php b/app/Livewire/Datatable/PerfumesTable.php
new file mode 100644
index 0000000..7f42f98
--- /dev/null
+++ b/app/Livewire/Datatable/PerfumesTable.php
@@ -0,0 +1,95 @@
+label(function ($row) {
+ return <<
+
{$row->name}
+ {$row->sku}
+
+ HTML;
+ })
+ ->searchable(function ($query, $searchTerm) {
+ $query->where('name', 'like', "%{$searchTerm}%")
+ ->orWhere('sku', 'like', "%{$searchTerm}%");
+ })
+ ->html(),
+
+ ArrayColumn::make('Kategori')
+ ->data(fn ($value, $row) => $row->categories->pluck('name')->toArray())
+ ->outputFormat(fn ($index, $value) => Blade::render(''.$value.''))
+ ->flexRow(['class' => 'gap-2 flex-wrap']),
+
+ Column::make('Merek', 'brand.name')->searchable()->sortable(),
+
+ Column::make('Concentration', 'concentration')
+ ->label(fn ($row, $column) => Blade::render(''.$row->concentration->label().''))
+ ->html(),
+
+ Column::make('Harga Beli', 'cost_price')
+ ->label(fn ($row, $column) => currency($row->cost_price, 'Rp'))
+ ->searchable()
+ ->sortable(),
+
+ Column::make('Harga Jual', 'sale_price')
+ ->label(fn ($row, $column) => currency($row->sale_price, 'Rp'))
+ ->searchable()
+ ->sortable(),
+
+ Column::make('Base Notes', 'base_notes')->searchable()->sortable(),
+
+ Column::make('Middle Notes', 'middle_notes')->searchable()->sortable(),
+
+ Column::make('Top Notes', 'top_notes')->searchable()->sortable(),
+
+ ArrayColumn::make('Outlet')
+ ->data(fn ($value, $row) => $row->outlets->pluck('name')->toArray())
+ ->outputFormat(fn ($index, $value) => Blade::render(''.$value.''))
+ ->flexRow(['class' => 'gap-2 flex-wrap']),
+
+ Column::make('Aksi')
+ ->label(function ($row) {
+ $actions = '';
+
+ $actions .= view('components.datatables.edit', [
+ 'id' => $row->id,
+ 'editRoute' => route('studio.catalog.perfume.edit', $row->id),
+ ])->render();
+
+ $actions .= view('components.datatables.delete', [
+ 'id' => $row->id,
+ 'deleteRoute' => route('studio.catalog.perfume.delete', $row->id),
+ ])->render();
+
+ return $actions;
+ })
+ ->html(),
+ ];
+ }
+
+ public function builder(): Builder
+ {
+ return Perfume::select('perfumes.id', 'sku', 'perfumes.name', 'concentration', 'cost_price', 'sale_price', 'base_notes', 'middle_notes', 'top_notes')->with(['categories', 'brand', 'outlets']);
+ }
+}
diff --git a/app/Livewire/Forms/PerfumeForm.php b/app/Livewire/Forms/PerfumeForm.php
new file mode 100644
index 0000000..c1a6e65
--- /dev/null
+++ b/app/Livewire/Forms/PerfumeForm.php
@@ -0,0 +1,161 @@
+ ['required', 'string', 'max:50'],
+ 'sku' => [
+ 'required',
+ 'string',
+ 'max:20',
+ Rule::unique('perfumes', 'sku')->ignore($this->perfume),
+ ],
+ 'brand' => ['nullable', Rule::exists('brands', 'id')],
+ 'cost_price' => ['required', 'numeric', new UnsignedInteger],
+ 'sale_price' => ['required', 'numeric', new UnsignedInteger],
+ 'base_notes' => ['nullable', 'string', 'max:255'],
+ 'middle_notes' => ['nullable', 'string', 'max:255'],
+ 'top_notes' => ['nullable', 'string', 'max:255'],
+ 'description' => ['nullable', 'string'],
+ 'concentration' => ['required', Rule::in(Concentration::values())],
+ 'category_ids' => ['required', 'array', 'min:1'],
+ 'category_ids.*' => Rule::exists('categories', 'id'),
+ 'outlet_ids' => ['required', 'array', 'min:1'],
+ 'outlet_ids.*' => Rule::exists('outlets', 'id'),
+ 'image' => ['nullable', 'array', 'max:1'],
+ ];
+ }
+
+ public function validationAttributes(): array
+ {
+ return [
+ 'name' => 'nama',
+ 'brand' => 'merek',
+ 'cost_price' => 'harga beli',
+ 'sale_price' => 'harga jual',
+ 'description' => 'deskripsi',
+ 'category_ids' => 'kategori',
+ 'outlet_ids' => 'outlet',
+ 'image' => 'gambar',
+ ];
+ }
+
+ public function setPerfume(Perfume $perfume)
+ {
+ $perfume->load(['brand', 'outlets', 'categories']);
+
+ $this->perfume = $perfume;
+
+ $this->name = $perfume->name;
+ $this->sku = $perfume->sku;
+ $this->brand = $perfume->brand_id;
+ $this->cost_price = $perfume->cost_price;
+ $this->sale_price = $perfume->sale_price;
+ $this->base_notes = $perfume->base_notes;
+ $this->middle_notes = $perfume->middle_notes;
+ $this->top_notes = $perfume->top_notes;
+ $this->description = $perfume->description;
+ $this->concentration = $perfume->concentration->value;
+ $this->category_ids = $perfume->categories->pluck('id')->toArray();
+ $this->outlet_ids = $this->perfume->outlets->pluck('id')->toArray();
+ $this->image = $this->mapMediaCollection($perfume->getMedia('image'));
+ }
+
+ public function store()
+ {
+ $this->validate();
+
+ $data = $this->prepareSavedData();
+
+ DB::transaction(function () use ($data) {
+ $perfume = Perfume::create($data);
+
+ $perfume->categories()->attach($this->category_ids);
+
+ $perfume->outlets()->attach($this->outlet_ids);
+
+ if ($this->image) {
+ $this->image = $this->mapMediaCollection($perfume->getMedia('image'));
+ }
+ });
+ }
+
+ public function update()
+ {
+ $this->validate();
+
+ $data = $this->prepareSavedData();
+
+ DB::transaction(function () use ($data) {
+ $this->perfume->update($data);
+
+ $this->perfume->categories()->sync($this->category_ids);
+
+ $this->perfume->outlets()->sync($this->outlet_ids);
+
+ $this->syncMedia($data['image'] ?? [], $this->perfume, 'image');
+ $this->uploadMedia($data['image'] ?? [], $this->perfume, 'image');
+ });
+ }
+
+ private function prepareSavedData()
+ {
+ return [
+ 'name' => $this->name,
+ 'sku' => $this->sku,
+ 'cost_price' => $this->cost_price,
+ 'sale_price' => $this->sale_price,
+ 'base_notes' => $this->base_notes,
+ 'middle_notes' => $this->middle_notes,
+ 'top_notes' => $this->top_notes,
+ 'description' => $this->description,
+ 'concentration' => $this->concentration,
+ 'brand_id' => $this->brand,
+ 'category_ids' => $this->category_ids,
+ 'outlet_ids' => $this->outlet_ids,
+ 'image' => $this->image,
+ ];
+ }
+}
diff --git a/app/Livewire/Studio/Catalog/Perfume/Create.php b/app/Livewire/Studio/Catalog/Perfume/Create.php
new file mode 100644
index 0000000..6ffa25f
--- /dev/null
+++ b/app/Livewire/Studio/Catalog/Perfume/Create.php
@@ -0,0 +1,60 @@
+outlets = Outlet::pluck('name', 'id')->toArray();
+
+ $this->brands = Brand::pluck('name', 'id')->toArray();
+
+ $this->categories = Category::pluck('name', 'id')->toArray();
+ }
+
+ public function save()
+ {
+ $this->form->store();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Parfum berhasil ditambahkan.',
+ variant: 'success',
+ duration: 3000
+ );
+
+ $this->redirectRoute('studio.catalog.perfume.index', navigate: true);
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.catalog.perfume.form', [
+ 'pageTitle' => 'Tambah Parfum',
+ ]);
+ }
+}
diff --git a/app/Livewire/Studio/Catalog/Perfume/Edit.php b/app/Livewire/Studio/Catalog/Perfume/Edit.php
new file mode 100644
index 0000000..462a27a
--- /dev/null
+++ b/app/Livewire/Studio/Catalog/Perfume/Edit.php
@@ -0,0 +1,63 @@
+form->setPerfume($perfume);
+
+ $this->outlets = Outlet::pluck('name', 'id')->toArray();
+
+ $this->brands = Brand::pluck('name', 'id')->toArray();
+
+ $this->categories = Category::pluck('name', 'id')->toArray();
+ }
+
+ public function save()
+ {
+ $this->form->update();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Parfum berhasil diperbarui.',
+ variant: 'success',
+ duration: 3000
+ );
+
+ $this->redirectRoute('studio.catalog.perfume.index', navigate: true);
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.catalog.perfume.form', [
+ 'pageTitle' => 'Ubah Parfum',
+ ]);
+ }
+}
diff --git a/app/Livewire/Studio/Catalog/Perfume/Index.php b/app/Livewire/Studio/Catalog/Perfume/Index.php
new file mode 100644
index 0000000..cab80bc
--- /dev/null
+++ b/app/Livewire/Studio/Catalog/Perfume/Index.php
@@ -0,0 +1,37 @@
+delete();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Parfum berhasil dihapus.',
+ variant: 'success',
+ );
+
+ Flux::modals()->close();
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.catalog.perfume.index', [
+ 'pageTitle' => 'Parfum',
+ ]);
+ }
+}
diff --git a/app/Models/Brand.php b/app/Models/Brand.php
index 6a05241..cdce4e9 100644
--- a/app/Models/Brand.php
+++ b/app/Models/Brand.php
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
@@ -29,4 +30,9 @@ public function getSlugOptions(): SlugOptions
->generateSlugsFrom('name')
->saveSlugsTo('slug');
}
+
+ public function perfumes(): HasMany
+ {
+ return $this->hasMany(Perfume::class);
+ }
}
diff --git a/app/Models/Category.php b/app/Models/Category.php
index 81cf951..e193ccd 100644
--- a/app/Models/Category.php
+++ b/app/Models/Category.php
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
@@ -27,4 +28,9 @@ public function getSlugOptions(): SlugOptions
->generateSlugsFrom('name')
->saveSlugsTo('slug');
}
+
+ public function perfumes(): BelongsToMany
+ {
+ return $this->belongsToMany(Perfume::class);
+ }
}
diff --git a/app/Models/CategoryPerfume.php b/app/Models/CategoryPerfume.php
new file mode 100644
index 0000000..fc8aa74
--- /dev/null
+++ b/app/Models/CategoryPerfume.php
@@ -0,0 +1,12 @@
+belongsToMany(Voucher::class);
}
+
+ public function perfumes(): BelongsToMany
+ {
+ return $this->belongsToMany(Perfume::class);
+ }
}
diff --git a/app/Models/OutletPerfume.php b/app/Models/OutletPerfume.php
new file mode 100644
index 0000000..d54106a
--- /dev/null
+++ b/app/Models/OutletPerfume.php
@@ -0,0 +1,12 @@
+ Concentration::class,
+ 'cost_price' => 'int',
+ 'sale_price' => 'int',
+ ];
+ }
+
+ public function getSlugOptions(): SlugOptions
+ {
+ return SlugOptions::create()
+ ->generateSlugsFrom('name')
+ ->saveSlugsTo('slug');
+ }
+
+ public function brand(): BelongsTo
+ {
+ return $this->belongsTo(Brand::class);
+ }
+
+ public function categories(): BelongsToMany
+ {
+ return $this->belongsToMany(Category::class);
+ }
+
+ public function outlets(): BelongsToMany
+ {
+ return $this->belongsToMany(Outlet::class);
+ }
+}
diff --git a/app/Traits/WithCategorySelector.php b/app/Traits/WithCategorySelector.php
new file mode 100644
index 0000000..b4aad4e
--- /dev/null
+++ b/app/Traits/WithCategorySelector.php
@@ -0,0 +1,16 @@
+form->category_ids = array_keys($this->categories);
+ }
+
+ public function deselectAllCategories()
+ {
+ $this->form->category_ids = [];
+ }
+}
diff --git a/database/migrations/2025_10_01_112147_create_perfumes_table.php b/database/migrations/2025_10_01_112147_create_perfumes_table.php
new file mode 100644
index 0000000..b42bbe4
--- /dev/null
+++ b/database/migrations/2025_10_01_112147_create_perfumes_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->foreignId('brand_id')->nullable()->constrained()->cascadeOnDelete();
+ $table->string('name', 50);
+ $table->string('slug', 70)->unique();
+ $table->string('sku', 20)->unique();
+ $table->enum('concentration', [Concentration::values()])->default(Concentration::EXTRAIT_DE_PERFUME)->comment(Concentration::comment());
+ $table->unsignedInteger('cost_price')->default(0);
+ $table->unsignedInteger('sale_price')->default(0);
+ $table->string('base_notes')->nullable();
+ $table->string('middle_notes')->nullable();
+ $table->string('top_notes')->nullable();
+ $table->text('description')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('perfumes');
+ }
+};
diff --git a/database/migrations/2025_10_01_112210_create_category_perfume_table.php b/database/migrations/2025_10_01_112210_create_category_perfume_table.php
new file mode 100644
index 0000000..3df3208
--- /dev/null
+++ b/database/migrations/2025_10_01_112210_create_category_perfume_table.php
@@ -0,0 +1,29 @@
+id();
+ $table->foreignId('category_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('perfume_id')->constrained()->cascadeOnDelete();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('category_perfume');
+ }
+};
diff --git a/database/migrations/2025_10_01_123039_create_outlet_perfume_table.php b/database/migrations/2025_10_01_123039_create_outlet_perfume_table.php
new file mode 100644
index 0000000..b0eb48f
--- /dev/null
+++ b/database/migrations/2025_10_01_123039_create_outlet_perfume_table.php
@@ -0,0 +1,30 @@
+id();
+ $table->foreignId('outlet_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('perfume_id')->constrained()->cascadeOnDelete();
+ $table->unsignedInteger('stock')->default(0);
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('outlet_perfume');
+ }
+};
diff --git a/resources/views/components/partials/studio/_sidebar.blade.php b/resources/views/components/partials/studio/_sidebar.blade.php
index 0073510..3fe89bc 100644
--- a/resources/views/components/partials/studio/_sidebar.blade.php
+++ b/resources/views/components/partials/studio/_sidebar.blade.php
@@ -48,6 +48,10 @@ class="bg-zinc-50 dark:bg-zinc-900 border-r rtl:border-r-0 rtl:border-l border-z
Merek
+
+ Parfum
+
diff --git a/resources/views/livewire/studio/catalog/perfume/form.blade.php b/resources/views/livewire/studio/catalog/perfume/form.blade.php
new file mode 100644
index 0000000..642f381
--- /dev/null
+++ b/resources/views/livewire/studio/catalog/perfume/form.blade.php
@@ -0,0 +1,162 @@
+
+
+
+ {{ $pageTitle }}
+
+
+
+ Kembali
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @foreach ($brands as $key => $value)
+
+ {{ $value }}
+
+ @endforeach
+
+
+
+ Harga Beli
+
+ Rp
+
+
+
+
+
+
+ Harga Jual
+
+ Rp
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @foreach (\App\Enums\Concentration::cases() as $item)
+
+ {{ $item->label() }}
+
+ @endforeach
+
+
+
+
+
+ Pilih Semua
+
+ Hapus Semua
+
+ @foreach ($categories as $key => $value)
+ {{ $value }}
+
+ @endforeach
+
+
+
+
+
+ Pilih Semua
+
+ Hapus Semua
+
+ @foreach ($outlets as $key => $value)
+ {{ $value }}
+
+ @endforeach
+
+
+
+
+
+
Gambar
+
+
+ @error('form.image')
+
+ @enderror
+
+
+
+
+
+
+
+
+ Simpan
+
+
+
+
+
diff --git a/resources/views/livewire/studio/catalog/perfume/index.blade.php b/resources/views/livewire/studio/catalog/perfume/index.blade.php
new file mode 100644
index 0000000..cd438ae
--- /dev/null
+++ b/resources/views/livewire/studio/catalog/perfume/index.blade.php
@@ -0,0 +1,19 @@
+
+
+
+ {{ $pageTitle }}
+
+
+
+ Tambah
+
+
+
+
+
+
+
+
+ @include('components.confirmation.delete')
+
diff --git a/routes/web.php b/routes/web.php
index 8c3418b..3bddc07 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -5,6 +5,9 @@
use App\Livewire\Auth\Register;
use App\Livewire\Studio\Catalog\Brand as BrandComponent;
use App\Livewire\Studio\Catalog\Category as CategoryComponent;
+use App\Livewire\Studio\Catalog\Perfume\Create as PerfumeCreate;
+use App\Livewire\Studio\Catalog\Perfume\Edit as PerfumeEdit;
+use App\Livewire\Studio\Catalog\Perfume\Index as PerfumeIndex;
use App\Livewire\Studio\Dashboard\Overview;
use App\Livewire\Studio\Loyalty\Customer as CustomerComponent;
use App\Livewire\Studio\Loyalty\Tier as TierComponent;
@@ -115,4 +118,13 @@
Route::get('brands', BrandComponent::class)->name('index');
Route::delete('brands/{brand}/delete', BrandComponent::class)->name('delete');
});
+
+ Route::prefix('catalog')
+ ->as('studio.catalog.perfume.')
+ ->group(function () {
+ Route::get('perfumes', PerfumeIndex::class)->name('index');
+ Route::get('perfumes/create', PerfumeCreate::class)->name('create');
+ Route::get('perfumes/{perfume}/edit', PerfumeEdit::class)->name('edit');
+ Route::get('perfumes/{perfume}/delete', PerfumeCreate::class)->name('delete');
+ });
});