feat(product): crud produk

-menambahkan skema model dan migrsai
-menyesuaikan relasi
-publish boxes icon
This commit is contained in:
Yoga Pangestu 2025-10-02 08:54:56 +07:00
parent 70ba0f7de8
commit 846ad189fb
15 changed files with 651 additions and 0 deletions

View File

@ -0,0 +1,78 @@
<?php
namespace App\Livewire\Datatable;
use App\Models\Product;
use App\Traits\Datatable\WithAppendColumn;
use App\Traits\Datatable\WithConfiguration;
use App\Traits\Datatable\WithPrependColumn;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Blade;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\ArrayColumn;
class ProductsTable extends DataTableComponent
{
use WithAppendColumn, WithConfiguration, WithPrependColumn;
protected $model = Product::class;
public function columns(): array
{
return [
Column::make('Nama')
->label(function ($row) {
return <<<HTML
<div>
<div>{$row->name}</div>
<span class="text-xs text-gray-400">{$row->sku}</span>
</div>
HTML;
})
->searchable(function ($query, $searchTerm) {
$query->where('name', 'like', "%{$searchTerm}%")
->orWhere('sku', 'like', "%{$searchTerm}%");
})
->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(),
ArrayColumn::make('Outlet')
->data(fn ($value, $row) => $row->outlets->pluck('name')->toArray())
->outputFormat(fn ($index, $value) => Blade::render('<span class="text-xs text-gray-400 border border-gray-400 rounded px-1">'.$value.'</span>'))
->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.product.edit', $row->id),
])->render();
$actions .= view('components.datatables.delete', [
'id' => $row->id,
'deleteRoute' => route('studio.catalog.product.delete', $row->id),
])->render();
return $actions;
})
->html(),
];
}
public function builder(): Builder
{
return Product::select('products.id', 'sku', 'products.name', 'cost_price', 'sale_price')->with('outlets');
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Livewire\Forms;
use App\Models\Product;
use App\Rules\UnsignedInteger;
use App\Traits\WithMediaHandler;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Form;
class ProductForm extends Form
{
use WithMediaHandler;
public ?Product $product = null;
public string $name = '';
public string $sku = '';
public string $cost_price = '';
public string $sale_price = '';
public ?string $description = null;
public array $image = [];
public array $outlet_ids = [];
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50'],
'sku' => [
'required',
'string',
'max:20',
Rule::unique('products', 'sku')->ignore($this->product),
],
'cost_price' => ['required', 'numeric', new UnsignedInteger],
'sale_price' => ['required', 'numeric', new UnsignedInteger],
'description' => ['nullable', 'string'],
'outlet_ids' => ['required', 'array', 'min:1'],
'outlet_ids.*' => Rule::exists('outlets', 'id'),
'image' => ['nullable', 'array', 'max:1'],
];
}
public function validationAttributes(): array
{
return [
'name' => 'nama',
'cost_price' => 'harga beli',
'sale_price' => 'harga jual',
'description' => 'deskripsi',
'outlet_ids' => 'outlet',
'image' => 'gambar',
];
}
public function setProduct(Product $product)
{
$product->load('outlets');
$this->product = $product;
$this->name = $product->name;
$this->sku = $product->sku;
$this->cost_price = $product->cost_price;
$this->sale_price = $product->sale_price;
$this->description = $product->description;
$this->outlet_ids = $this->product->outlets->pluck('id')->toArray();
$this->image = $this->mapMediaCollection($product->getMedia('image'));
}
public function store()
{
$this->validate();
$data = $this->prepareSavedData();
DB::transaction(function () use ($data) {
$product = Product::create($data);
$product->outlets()->attach($this->outlet_ids);
$this->uploadMedia($this->image, $product, 'image');
});
}
public function update()
{
$this->validate();
$data = $this->prepareSavedData();
DB::transaction(function () use ($data) {
$this->product->update($data);
$this->product->outlets()->sync($this->outlet_ids);
$this->syncMedia($data['image'], $this->product, 'image');
$this->uploadMedia($data['image'], $this->product, 'image');
});
}
private function prepareSavedData()
{
return [
'name' => $this->name,
'sku' => $this->sku,
'cost_price' => $this->cost_price,
'sale_price' => $this->sale_price,
'description' => $this->description,
'outlet_ids' => $this->outlet_ids,
'image' => $this->image,
];
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Studio\Catalog\Product;
use App\Livewire\Forms\ProductForm;
use App\Models\Outlet;
use App\Traits\WithOutletSelector;
use App\Traits\WithUpdatedData;
use Flux\Flux;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Tambah Produk')]
class Create extends Component
{
use WithOutletSelector, WithUpdatedData;
public ProductForm $form;
public array $outlets = [];
public function mount()
{
$this->outlets = Outlet::pluck('name', 'id')->toArray();
}
public function save()
{
$this->form->store();
$this->dispatch('refreshDatatable');
Flux::toast(
heading: 'Berhasil',
text: 'Produk berhasil ditambahkan.',
variant: 'success',
duration: 3000
);
$this->redirectRoute('studio.catalog.product.index', navigate: true);
}
public function render()
{
return view('livewire.studio.catalog.product.form', [
'pageTitle' => 'Tambah Produk',
]);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Studio\Catalog\Product;
use App\Livewire\Forms\ProductForm;
use App\Models\Outlet;
use App\Models\Product;
use App\Traits\WithOutletSelector;
use App\Traits\WithUpdatedData;
use Flux\Flux;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Ubah Produk')]
class Edit extends Component
{
use WithOutletSelector, WithUpdatedData;
public ProductForm $form;
public array $outlets = [];
public function mount(Product $product)
{
$this->form->setProduct($product);
$this->outlets = Outlet::pluck('name', 'id')->toArray();
}
public function save()
{
$this->form->update();
$this->dispatch('refreshDatatable');
Flux::toast(
heading: 'Berhasil',
text: 'Produk berhasil diperbarui.',
variant: 'success',
duration: 3000
);
$this->redirectRoute('studio.catalog.product.index', navigate: true);
}
public function render()
{
return view('livewire.studio.catalog.product.form', [
'pageTitle' => 'Ubah Produk',
]);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Livewire\Studio\Catalog\Product;
use App\Models\Product;
use App\Traits\WithConfirmation;
use Flux\Flux;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Produk')]
class Index extends Component
{
use WithConfirmation;
public function delete(Product $product)
{
$product->delete();
$this->dispatch('refreshDatatable');
Flux::toast(
heading: 'Berhasil',
text: 'Produk berhasil dihapus.',
variant: 'success',
);
Flux::modals()->close();
}
public function render()
{
return view('livewire.studio.catalog.product.index', [
'pageTitle' => 'Produk',
]);
}
}

View File

@ -57,4 +57,9 @@ public function perfumes(): BelongsToMany
{
return $this->belongsToMany(Perfume::class);
}
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class OutletProduct extends Model
{
protected $table = 'outlet_product';
protected $guarded = ['id'];
}

39
app/Models/Product.php Normal file
View File

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
class Product extends Model implements HasMedia
{
use HasFactory, HasSlug, InteractsWithMedia, SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'cost_price' => 'int',
'sale_price' => 'int',
];
}
public function getSlugOptions(): SlugOptions
{
return SlugOptions::create()
->generateSlugsFrom('name')
->saveSlugsTo('slug');
}
public function outlets(): BelongsToMany
{
return $this->belongsToMany(Outlet::class);
}
}

View File

@ -0,0 +1,34 @@
<?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::create('products', function (Blueprint $table) {
$table->id();
$table->string('name', 50);
$table->string('slug', 70)->unique();
$table->string('sku', 20)->unique();
$table->unsignedInteger('cost_price')->default(0);
$table->unsignedInteger('sale_price')->default(0);
$table->text('description')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('products');
}
};

View File

@ -0,0 +1,30 @@
<?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::create('outlet_product', function (Blueprint $table) {
$table->id();
$table->foreignId('outlet_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('stock')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('outlet_product');
}
};

View File

@ -52,6 +52,10 @@ class="bg-zinc-50 dark:bg-zinc-900 border-r rtl:border-r-0 rtl:border-l border-z
<flux:navlist.item icon="flower-2" href="{{ route('studio.catalog.perfume.index') }}"
:current="request()->routeIs('studio.catalog.perfume.*')" wire:navigate.hover>Parfum
</flux:navlist.item>
<flux:navlist.item icon="boxes" href="{{ route('studio.catalog.product.index') }}"
:current="request()->routeIs('studio.catalog.product.*')" wire:navigate.hover>Produk
</flux:navlist.item>
</div>
</div>
</flux:navlist>

View File

@ -0,0 +1,52 @@
{{-- Credit: Lucide (https://lucide.dev) --}}
@props([
'variant' => 'outline',
])
@php
if ($variant === 'solid') {
throw new \Exception('The "solid" variant is not supported in Lucide.');
}
$classes = Flux::classes('shrink-0')
->add(match($variant) {
'outline' => '[:where(&)]:size-6',
'solid' => '[:where(&)]:size-6',
'mini' => '[:where(&)]:size-5',
'micro' => '[:where(&)]:size-4',
});
$strokeWidth = match ($variant) {
'outline' => 2,
'mini' => 2.25,
'micro' => 2.5,
};
@endphp
<svg
{{ $attributes->class($classes) }}
data-flux-icon
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="{{ $strokeWidth }}"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-slot="icon"
>
<path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" />
<path d="m7 16.5-4.74-2.85" />
<path d="m7 16.5 5-3" />
<path d="M7 16.5v5.17" />
<path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" />
<path d="m17 16.5-5-3" />
<path d="m17 16.5 4.74-2.85" />
<path d="M17 16.5v5.17" />
<path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z" />
<path d="M12 8 7.26 5.15" />
<path d="m12 8 4.74-2.85" />
<path d="M12 13.5V8" />
</svg>

View File

@ -0,0 +1,107 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
<div>
<flux:button href="{{ route('studio.catalog.product.index') }}" wire:navigate.hover class="text-sm">
Kembali
</flux:button>
</div>
</div>
<div class="mt-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col lg:flex-row gap-4">
<div class="w-full lg:w-3/4 space-y-4">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 items-start">
<div class="col-span-1 lg:col-span-2">
<flux:card class="space-y-6 p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<flux:input label="Nama" placeholder="Masukkan nama produk"
wire:model.live.debounce.500ms="form.name" autofocus autocomplete="off" />
<flux:input label="SKU" placeholder="Masukkan stock keeping unit"
wire:model.live.debounce.500ms="form.sku" autocomplete="off" copyable />
<flux:field>
<flux:label>Harga Beli</flux:label>
<flux:input.group>
<flux:input.group.prefix>Rp</flux:input.group.prefix>
<flux:input placeholder="Masukkan harga beli"
x-mask:dynamic="$money($input, ',')"
wire:model.live.debounce.500ms="form.cost_price" autocomplete="off" />
</flux:input.group>
<flux:error name="form.cost_price" />
</flux:field>
<flux:field>
<flux:label>Harga Jual</flux:label>
<flux:input.group>
<flux:input.group.prefix>Rp</flux:input.group.prefix>
<flux:input placeholder="Masukkan harga jual"
x-mask:dynamic="$money($input, ',')"
wire:model.live.debounce.500ms="form.sale_price" autocomplete="off" />
</flux:input.group>
<flux:error name="form.sale_price" />
</flux:field>
<div class="md:col-span-2">
<flux:editor label="Deskripsi" wire:model="form.description"
placeholder="Masukkan deskripsi" auotocomplete="off"
class="**:data-[slot=content]:min-h-[100px]!" />
</div>
</div>
</flux:card>
</div>
</div>
</div>
<div class="w-full lg:w-1/3 space-y-4">
<flux:card class="space-y-6 p-6">
<flux:select variant="listbox" multiple searchable placeholder="Pilih Outlet" label="Outlet"
wire:model.live.debounce.500ms="form.outlet_ids">
<flux:select.option wire:click="selectAllOutlets" wire:ignore>Pilih Semua
</flux:select.option>
<flux:select.option wire:click="deselectAllOutlets" wire:ignore>Hapus Semua
</flux:select.option>
@foreach ($outlets as $key => $value)
<flux:select.option value="{{ $key }}">{{ $value }}
</flux:select.option>
@endforeach
</flux:select>
</flux:card>
<flux:card class="space-y-6 p-6">
<div class="space-y-3">
<h3 class="text-sm font-medium">Gambar</h3>
<div class="dropzone-wrapper">
<livewire:dropzone wire:model="form.image" :rules="['image', 'mimes:png,jpeg', 'max:10420']" :max-files="1"
:key="'image'" :files="$form->image" />
@error('form.image')
<div role="alert" aria-live="polite" aria-atomic="true"
class="mt-3 text-sm font-medium text-red-500 dark:text-red-400">
<svg class="shrink-0 [:where(&amp;)]:size-5 inline" data-flux-icon=""
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clip-rule="evenodd"></path>
</svg>
{{ $message }}
</div>
@enderror
</div>
</div>
</flux:card>
</div>
</div>
<div class="flex justify-start">
<flux:button variant="primary" class="sm:w-auto cursor-pointer" wire:click="save">
Simpan
</flux:button>
</div>
</div>
</div>
</flux:main>

View File

@ -0,0 +1,19 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
<div>
<flux:button href="{{ route('studio.catalog.product.create') }}" variant="primary" wire:navigate.hover
class="text-sm">
Tambah
</flux:button>
</div>
</div>
<div class="mt-6">
<livewire:datatable.products-table />
</div>
@include('components.confirmation.delete')
</flux:main>

View File

@ -8,6 +8,9 @@
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\Catalog\Product\Create as ProductCreate;
use App\Livewire\Studio\Catalog\Product\Edit as ProductEdit;
use App\Livewire\Studio\Catalog\Product\Index as ProductIndex;
use App\Livewire\Studio\Dashboard\Overview;
use App\Livewire\Studio\Loyalty\Customer as CustomerComponent;
use App\Livewire\Studio\Loyalty\Tier as TierComponent;
@ -127,4 +130,13 @@
Route::get('perfumes/{perfume}/edit', PerfumeEdit::class)->name('edit');
Route::get('perfumes/{perfume}/delete', PerfumeCreate::class)->name('delete');
});
Route::prefix('catalog')
->as('studio.catalog.product.')
->group(function () {
Route::get('products', ProductIndex::class)->name('index');
Route::get('products/create', ProductCreate::class)->name('create');
Route::get('products/{product}/edit', ProductEdit::class)->name('edit');
Route::get('products/{product}/delete', ProductCreate::class)->name('delete');
});
});