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.*', 'match' => 'studio.loyalty.voucher.*',
'can' => 'view voucher', 'can' => 'view voucher',
], ],
[
'label' => 'Hadiah',
'icon' => 'gift',
'route' => 'studio.loyalty.reward.index',
'match' => 'studio.loyalty.reward.*',
'can' => 'view reward',
],
[ [
'label' => 'Customer', 'label' => 'Customer',
'icon' => 'user-plus', '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', 'update voucher',
'delete voucher', 'delete voucher',
'view reward',
'create reward',
'update reward',
'delete reward',
'view customer', 'view customer',
'create customer', 'create customer',
'update customer', 'update customer',
@ -224,6 +229,10 @@ public function run(): void
'update voucher', 'update voucher',
'delete voucher', 'delete voucher',
'create reward',
'update reward',
'delete reward',
'create perfume', 'create perfume',
'update perfume', 'update perfume',
'delete perfume', 'delete perfume',
@ -320,6 +329,11 @@ public function run(): void
'update voucher', 'update voucher',
'delete voucher', 'delete voucher',
'view reward',
'create reward',
'update reward',
'delete reward',
'view customer', 'view customer',
'create customer', 'create customer',
'update 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\Faq as FaqComponent;
use App\Livewire\Studio\Information\PriceRequest as PriceRequestComponent; use App\Livewire\Studio\Information\PriceRequest as PriceRequestComponent;
use App\Livewire\Studio\Loyalty\Customer as CustomerComponent; 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\Tier as TierComponent;
use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate; use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate;
use App\Livewire\Studio\Loyalty\Voucher\Edit as VoucherEdit; 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('/create', VoucherCreate::class)->name('create')->middleware('can:create voucher');
Route::get('/{voucher}/edit', VoucherEdit::class)->name('edit')->middleware('can:update 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 () { Route::prefix('catalog')->name('studio.catalog.')->group(function () {