feat: Implement loyalty reward management with dedicated routes, model, UI, and permissions.

This commit is contained in:
Yoga Pangestu 2025-12-18 20:16:37 +07:00
parent b132f86ed7
commit e07a2386cf
9 changed files with 416 additions and 0 deletions

View File

@ -0,0 +1,65 @@
<?php
namespace App\Livewire\Datatable\Loyalty;
use App\Models\Reward;
use App\Traits\Datatable\WithAppendColumn;
use App\Traits\Datatable\WithConfiguration;
use App\Traits\Datatable\WithPrependColumn;
use Illuminate\Database\Eloquent\Builder;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class RewardsTable extends DataTableComponent
{
use WithAppendColumn, WithConfiguration, WithPrependColumn;
protected $model = Reward::class;
public function columns(): array
{
return [
Column::make('Nama', 'name')
->searchable()
->sortable(),
Column::make('Poin', 'points')
->format(fn ($value) => formatCurrencyNumber($value))
->searchable()
->sortable(),
Column::make('Stok', 'stock')
->format(fn ($value) => $value === null ? 'Tidak Terbatas' : formatCurrencyNumber($value))
->searchable()
->sortable(),
Column::make('Aksi')
->label(function ($row) {
$actions = '';
if (auth()->user()->can('update reward')) {
$actions .= view('components.actions.table.edit-modal', [
'id' => $row->hash,
'method' => 'update',
'modalTitle' => 'Ubah Hadiah',
])->render();
}
if (auth()->user()->can('delete reward')) {
$actions .= view('components.actions.table.delete', [
'id' => $row->hash,
])->render();
}
return $actions;
})
->html()
->hideIf(auth()->user()->cannot('update reward') && auth()->user()->cannot('delete reward')),
];
}
public function builder(): Builder
{
return Reward::select('id', 'name', 'points', 'stock');
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Livewire\Forms\Studio\Loyalty;
use App\Models\Reward;
use App\Rules\UnsignedInteger;
use App\Traits\Media\WithMediaHandler;
use Illuminate\Support\Facades\DB;
use Livewire\Form;
class RewardForm extends Form
{
use WithMediaHandler;
public ?Reward $reward = null;
public string $name = '';
public string $points = '';
public ?string $stock = null;
public string $how_to_get = '';
public array $thumbnail = [];
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'points' => ['required', new UnsignedInteger],
'stock' => ['nullable', new UnsignedInteger],
'how_to_get' => ['required', 'string'],
'thumbnail' => 'array',
];
}
public function validationAttributes(): array
{
return [
'name' => 'nama',
'points' => 'poin',
'stock' => 'stok',
'how_to_get' => 'cara mendapatkan',
];
}
public function setReward(Reward $reward): void
{
$this->reward = $reward;
$this->name = $reward->name;
$this->points = formatCurrencyNumber($reward->points);
$this->stock = formatCurrencyNumber($reward->stock);
$this->how_to_get = $reward->how_to_get ?? '';
$this->thumbnail = $this->mapMediaCollection($reward->getMedia('thumbnail'));
}
public function store(): void
{
$this->validate();
DB::transaction(function () {
$reward = Reward::create([
'name' => $this->name,
'points' => parseRupiahToInt($this->points),
'stock' => parseRupiahToInt($this->stock),
'how_to_get' => $this->how_to_get,
]);
$this->uploadMedia($this->thumbnail, $reward, 'thumbnail');
});
}
public function update(): void
{
$this->validate();
DB::transaction(function () {
$this->reward->update([
'name' => $this->name,
'points' => parseRupiahToInt($this->points),
'stock' => $this->stock ? parseRupiahToInt($this->stock) : null,
'how_to_get' => $this->how_to_get,
]);
$this->syncMedia($this->thumbnail, $this->reward, 'thumbnail');
$this->uploadMedia($this->thumbnail, $this->reward, 'thumbnail');
});
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Livewire\Studio\Loyalty;
use App\Livewire\Forms\Studio\Loyalty\RewardForm;
use App\Models\Reward as RewardModel;
use App\Traits\Authorization\WithAuthorization;
use App\Traits\Components\WithCloseModal;
use App\Traits\Components\WithConfirmation;
use App\Traits\Components\WithToast;
use App\Traits\Notification\WithSubscribeNotification;
use App\Traits\Utilities\WithUpdatedData;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Hadiah')]
class Reward extends Component
{
use WithAuthorization, WithCloseModal, WithConfirmation, WithSubscribeNotification, WithToast, WithUpdatedData;
public RewardForm $form;
public string $method = 'create';
public string $modalTitle = '';
public function create(): void
{
$this->canOrAbort('create reward');
$this->form->store();
$this->dispatch('refreshDatatable');
$this->toast('Hadiah berhasil ditambahkan.');
Flux::modals()->close();
}
public function update(): void
{
$this->canOrAbort('update reward');
$this->form->update();
$this->dispatch('refreshDatatable');
$this->toast('Hadiah berhasil diperbarui.');
Flux::modals()->close();
}
public function delete(RewardModel $reward): void
{
$this->canOrAbort('delete reward');
$reward->delete();
$this->dispatch('refreshDatatable');
$this->toast('Hadiah berhasil dihapus.');
Flux::modals()->close();
}
#[On('modal:open')]
public function openModal(string $method, string $modalTitle, ?string $id = null): void
{
$this->resetValidation();
$this->resetErrorBag();
$this->method = $method;
$this->modalTitle = $modalTitle;
$id && $this->form->setReward(RewardModel::byHashOrFail($id));
}
public function render(): View
{
return view('livewire.studio.loyalty.rewards', [
'pageTitle' => 'Hadiah',
]);
}
}

25
app/Models/Reward.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Veelasky\LaravelHashId\Eloquent\HashableId;
class Reward extends Model implements HasMedia
{
use HasFactory, HashableId, InteractsWithMedia, SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'points' => 'int',
'stock' => 'int',
];
}
}

View File

@ -89,6 +89,13 @@ public function boot(): void
'match' => 'studio.loyalty.voucher.*',
'can' => 'view voucher',
],
[
'label' => 'Hadiah',
'icon' => 'gift',
'route' => 'studio.loyalty.reward.index',
'match' => 'studio.loyalty.reward.*',
'can' => 'view reward',
],
[
'label' => 'Customer',
'icon' => 'user-plus',

View File

@ -0,0 +1,32 @@
<?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('rewards', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('how_to_get');
$table->unsignedInteger('points');
$table->unsignedInteger('stock')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('rewards');
}
};

View File

@ -62,6 +62,11 @@ public function run(): void
'update voucher',
'delete voucher',
'view reward',
'create reward',
'update reward',
'delete reward',
'view customer',
'create customer',
'update customer',
@ -224,6 +229,10 @@ public function run(): void
'update voucher',
'delete voucher',
'create reward',
'update reward',
'delete reward',
'create perfume',
'update perfume',
'delete perfume',
@ -320,6 +329,11 @@ public function run(): void
'update voucher',
'delete voucher',
'view reward',
'create reward',
'update reward',
'delete reward',
'view customer',
'create customer',
'update customer',

View File

@ -0,0 +1,90 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
<div class="flex gap-2">
@can('create reward')
<flux:modal.trigger name="form-modal">
<flux:button variant="primary" class="text-sm"
wire:click="$dispatch('modal:open', {method: 'create', 'modalTitle': 'Tambah Hadiah'})">Tambah
</flux:button>
</flux:modal.trigger>
@endcan
</div>
</div>
<div class="mt-6">
<livewire:datatable.loyalty.rewards-table />
</div>
@include('components.modals.confirmation', [
'modalName' => 'delete-confirmation',
'modalTitle' => 'Apakah Anda yakin?',
'modalMessage' => 'Data yang berelasi dengan data ini juga akan ikut terhapus.',
'buttonVariant' => 'primary',
'buttonColor' => 'danger',
'buttonText' => 'Ya, Hapus',
])
<flux:modal name="form-modal" class="w-[95%] max-w-sm md:max-w-xl mx-auto" @close="closeModal('form-modal')">
<div class="p-4 space-y-6">
<flux:heading size="lg">{{ $modalTitle }}</flux:heading>
<flux:field>
<flux:label>Nama <span class="text-red-500 ms-1">*</span></flux:label>
<flux:input placeholder="Blue Emotion 10ml" wire:model="form.name" autofocus autocomplete="off" />
<flux:error name="form.name" />
</flux:field>
<flux:field>
<flux:label>Poin <span class="text-red-500 ms-1">*</span></flux:label>
<flux:input placeholder="300" x-mask:dynamic="$money($input, ',')" wire:model="form.points"
autocomplete="off" />
<flux:error name="form.points" />
</flux:field>
<flux:field>
<flux:label>Stok </flux:label>
<flux:description>Kosongkan jika tidak ada batas maksimum.</flux:description>
<flux:input placeholder="300" x-mask:dynamic="$money($input, ',')" wire:model="form.stock"
autocomplete="off" />
<flux:error name="form.stock" />
</flux:field>
<flux:field>
<flux:label>Cara Mendapatkan <span class="text-red-500 ms-1">*</span></flux:label>
<flux:editor wire:model="form.how_to_get" placeholder="..." autocomplete="off"
class="**:data-[slot=content]:min-h-[100px]!" />
<flux:error name="form.how_to_get" />
</flux:field>
<div class="space-y-3">
<h3 class="text-sm font-medium">Thumbnail</h3>
<div class="dropzone-wrapper">
<livewire:dropzone wire:model="form.thumbnail" :rules="['image', 'mimes:png,jpeg', 'max:10420']" :max-files="1" :key="'thumbnail'"
:files="$form->thumbnail ?? []" />
@error('form.thumbnail')
<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>
<div class="flex">
<flux:spacer />
<flux:button variant="primary" class="sm:w-auto cursor-pointer" wire:click="{{ $method }}">
Simpan
</flux:button>
</div>
</div>
</flux:modal>
</flux:main>

View File

@ -25,6 +25,7 @@
use App\Livewire\Studio\Information\Faq as FaqComponent;
use App\Livewire\Studio\Information\PriceRequest as PriceRequestComponent;
use App\Livewire\Studio\Loyalty\Customer as CustomerComponent;
use App\Livewire\Studio\Loyalty\Reward as RewardComponent;
use App\Livewire\Studio\Loyalty\Tier as TierComponent;
use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate;
use App\Livewire\Studio\Loyalty\Voucher\Edit as VoucherEdit;
@ -94,6 +95,10 @@
Route::get('/create', VoucherCreate::class)->name('create')->middleware('can:create voucher');
Route::get('/{voucher}/edit', VoucherEdit::class)->name('edit')->middleware('can:update voucher');
});
Route::prefix('rewards')->name('reward.')->group(function () {
Route::get('/', RewardComponent::class)->name('index')->middleware('can:view reward');
});
});
Route::prefix('catalog')->name('studio.catalog.')->group(function () {