feat: Allow vouchers to be assigned to specific loyalty tiers.

This commit is contained in:
Yoga Pangestu 2025-12-18 14:18:12 +07:00
parent cc735e461d
commit d25dd7b9c3
12 changed files with 150 additions and 30 deletions

View File

@ -41,43 +41,50 @@ public function columns(): array
->sortable(), ->sortable(),
Column::make('Min. Belanja', 'min_purchase') Column::make('Min. Belanja', 'min_purchase')
->format(fn ($value) => formatCurrencyNumber($value, 'Rp')) ->format(fn($value) => formatCurrencyNumber($value, 'Rp'))
->searchable() ->searchable()
->sortable(), ->sortable(),
ArrayColumn::make('Tier')
->data(
fn($value, $row) => $row->tiers->pluck('name')->toArray() ?: ['Semua Tier']
)
->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('Jumlah Diskon', 'discount_amount') Column::make('Jumlah Diskon', 'discount_amount')
->label(fn ($row, $column) => formatDiscount($row->discount_amount, $row->type)) ->label(fn($row, $column) => formatDiscount($row->discount_amount, $row->type))
->searchable() ->searchable()
->sortable(), ->sortable(),
Column::make('Maks. Diskon') Column::make('Maks. Diskon')
->label(fn ($row, $column) => formatCurrencyNumber($row->max_discount, 'Rp')) ->label(fn($row, $column) => formatCurrencyNumber($row->max_discount, 'Rp'))
->searchable() ->searchable()
->sortable(), ->sortable(),
Column::make('Kuota', 'quota') Column::make('Kuota', 'quota')
->format(fn ($value) => $value ? formatCurrencyNumber($value) : 'Tidak Terbatas') ->format(fn($value) => $value ? formatCurrencyNumber($value) : 'Tidak Terbatas')
->searchable() ->searchable()
->sortable(), ->sortable(),
Column::make('Sisa Voucher', 'available_count') Column::make('Sisa Voucher', 'available_count')
->format(fn ($value) => $value ? formatCurrencyNumber($value) : 'Tidak Terbatas') ->format(fn($value) => $value ? formatCurrencyNumber($value) : 'Tidak Terbatas')
->searchable() ->searchable()
->sortable(), ->sortable(),
Column::make('Batas Per Pembali', 'limit_per_user') Column::make('Batas Per Pembali', 'limit_per_user')
->format(fn ($value) => $value ? $value : 'Tidak Terbatas') ->format(fn($value) => $value ? $value : 'Tidak Terbatas')
->searchable() ->searchable()
->sortable(), ->sortable(),
ArrayColumn::make('Outlet') ArrayColumn::make('Outlet')
->data( ->data(
fn ($value, $row) => $row->outlets->map(fn ($outlet) => [ fn($value, $row) => $row->outlets->map(fn($outlet) => [
'name' => $outlet->name, 'name' => $outlet->name,
'stock' => formatCurrencyNumber($outlet->pivot->stock, ''), 'stock' => formatCurrencyNumber($outlet->pivot->stock, ''),
])->toArray() ])->toArray()
) )
->outputFormat(fn ($index, $value) => Blade::render(' ->outputFormat(fn($index, $value) => Blade::render('
<div class="flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg px-2 py-1 text-xs"> <div class="flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg px-2 py-1 text-xs">
<span class="font-medium text-gray-700">{{ $value["name"] }}</span> <span class="font-medium text-gray-700">{{ $value["name"] }}</span>
</div>', ['value' => $value])) </div>', ['value' => $value]))
@ -134,11 +141,11 @@ public function columns(): array
public function builder(): Builder public function builder(): Builder
{ {
return Voucher::select('id', 'name', 'code', 'tags', 'min_purchase', 'discount_amount', 'max_discount', 'quota', 'available_count', 'limit_per_user', 'start_date', 'end_date', 'type', 'created_at') return Voucher::select('vouchers.id', 'vouchers.name', 'code', 'tags', 'min_purchase', 'discount_amount', 'max_discount', 'quota', 'available_count', 'limit_per_user', 'start_date', 'end_date', 'type', 'vouchers.created_at')
->with('outlets') ->with(['outlets', 'tiers'])
->whereHas( ->whereHas(
'outlets', 'outlets',
fn ($q) => $q->whereIn('outlets.id', auth()->user()->outlets()->pluck('outlets.id')) fn($q) => $q->whereIn('outlets.id', auth()->user()->outlets()->pluck('outlets.id'))
); );
} }
} }

View File

@ -37,6 +37,8 @@ class VoucherForm extends Form
public ?string $end_date = null; public ?string $end_date = null;
public array $tier_ids = [];
public array $outlet_ids = []; public array $outlet_ids = [];
public function rules(): array public function rules(): array
@ -73,6 +75,8 @@ public function rules(): array
'summary' => ['required', 'string', 'max:200'], 'summary' => ['required', 'string', 'max:200'],
'start_date' => ['required', 'date', 'after:yesterday'], 'start_date' => ['required', 'date', 'after:yesterday'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'tier_ids' => ['nullable', 'array'],
'tier_ids.*' => Rule::exists('tiers', 'id'),
'outlet_ids' => ['required', 'array', 'min:1'], 'outlet_ids' => ['required', 'array', 'min:1'],
'outlet_ids.*' => Rule::exists('outlets', 'id'), 'outlet_ids.*' => Rule::exists('outlets', 'id'),
]; ];
@ -92,6 +96,8 @@ public function validationAttributes(): array
'discount_amount' => 'Jumlah Diskon', 'discount_amount' => 'Jumlah Diskon',
'start_date' => 'tanggal mulai', 'start_date' => 'tanggal mulai',
'end_date' => 'tanggal selesai', 'end_date' => 'tanggal selesai',
'tier_ids' => 'tier',
'tier_ids.*' => 'tier',
'outlet_ids' => 'outlet', 'outlet_ids' => 'outlet',
'outlet_ids.*' => 'outlet', 'outlet_ids.*' => 'outlet',
]; ];
@ -99,7 +105,7 @@ public function validationAttributes(): array
public function setVoucher(Voucher $voucher): void public function setVoucher(Voucher $voucher): void
{ {
$voucher->load('outlets'); $voucher->load(['outlets', 'tiers']);
$this->voucher = $voucher; $this->voucher = $voucher;
@ -115,6 +121,8 @@ public function setVoucher(Voucher $voucher): void
$this->summary = $this->voucher->summary; $this->summary = $this->voucher->summary;
$this->start_date = $this->voucher->start_date; $this->start_date = $this->voucher->start_date;
$this->end_date = $this->voucher->end_date; $this->end_date = $this->voucher->end_date;
$this->tier_ids = $this->voucher->tiers->pluck('id')->toArray();
$this->outlet_ids = $this->voucher->outlets->pluck('id')->toArray(); $this->outlet_ids = $this->voucher->outlets->pluck('id')->toArray();
} }
@ -135,6 +143,7 @@ public function store(): void
$voucher = Voucher::create($data); $voucher = Voucher::create($data);
$voucher->outlets()->attach($this->outlet_ids); $voucher->outlets()->attach($this->outlet_ids);
$voucher->tiers()->attach($this->tier_ids);
}); });
} }
@ -149,6 +158,7 @@ public function update(): void
$this->voucher->update($data); $this->voucher->update($data);
$this->voucher->outlets()->sync($this->outlet_ids); $this->voucher->outlets()->sync($this->outlet_ids);
$this->voucher->tiers()->sync($this->tier_ids);
}); });
} }

View File

@ -6,6 +6,7 @@
use App\Livewire\Forms\Studio\Loyalty\VoucherForm; use App\Livewire\Forms\Studio\Loyalty\VoucherForm;
use App\Models\Outlet; use App\Models\Outlet;
use App\Models\PushNotification; use App\Models\PushNotification;
use App\Models\Tier;
use App\Traits\Authorization\WithAuthorization; use App\Traits\Authorization\WithAuthorization;
use App\Traits\Components\WithOutletSelector; use App\Traits\Components\WithOutletSelector;
use App\Traits\Components\WithToast; use App\Traits\Components\WithToast;
@ -23,9 +24,12 @@ class Create extends Component
public array $outlets; public array $outlets;
public array $tiers;
public function mount(): void public function mount(): void
{ {
$this->outlets = Outlet::pluck('name', 'id')->toArray(); $this->outlets = Outlet::pluck('name', 'id')->toArray();
$this->tiers = Tier::pluck('name', 'id')->toArray();
} }
public function save(): void public function save(): void

View File

@ -4,6 +4,7 @@
use App\Livewire\Forms\Studio\Loyalty\VoucherForm; use App\Livewire\Forms\Studio\Loyalty\VoucherForm;
use App\Models\Outlet; use App\Models\Outlet;
use App\Models\Tier;
use App\Models\Voucher; use App\Models\Voucher;
use App\Traits\Authorization\WithAuthorization; use App\Traits\Authorization\WithAuthorization;
use App\Traits\Components\WithOutletSelector; use App\Traits\Components\WithOutletSelector;
@ -22,11 +23,14 @@ class Edit extends Component
public array $outlets; public array $outlets;
public array $tiers;
public function mount(Voucher $voucher): void public function mount(Voucher $voucher): void
{ {
$this->form->setVoucher($voucher); $this->form->setVoucher($voucher);
$this->outlets = Outlet::pluck('name', 'id')->toArray(); $this->outlets = Outlet::pluck('name', 'id')->toArray();
$this->tiers = Tier::pluck('name', 'id')->toArray();
} }
public function save(): void public function save(): void

View File

@ -5,6 +5,7 @@
use Dyrynda\Database\Support\CascadeSoftDeletes; use Dyrynda\Database\Support\CascadeSoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Veelasky\LaravelHashId\Eloquent\HashableId; use Veelasky\LaravelHashId\Eloquent\HashableId;
@ -48,6 +49,11 @@ public function rewards(): HasMany
return $this->hasMany(TierReward::class); return $this->hasMany(TierReward::class);
} }
public function vouchers(): BelongsToMany
{
return $this->belongsToMany(Voucher::class);
}
public static function getHighestTier() public static function getHighestTier()
{ {
return self::orderByDesc('min_points')->first(); return self::orderByDesc('min_points')->first();

View File

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

View File

@ -53,6 +53,15 @@ public function upcoming(Builder $query): void
$query->whereDate('start_date', '>', now()); $query->whereDate('start_date', '>', now());
} }
#[Scope]
public function forTier(Builder $query, ?int $tierId): void
{
$query->where(function ($q) use ($tierId) {
$q->whereDoesntHave('tiers')
->when($tierId, fn ($query) => $query->orWhereHas('tiers', fn ($q) => $q->where('tiers.id', $tierId)));
});
}
public function outlets(): BelongsToMany public function outlets(): BelongsToMany
{ {
return $this->belongsToMany(Outlet::class); return $this->belongsToMany(Outlet::class);
@ -63,6 +72,11 @@ public function orders(): HasMany
return $this->hasMany(Order::class); return $this->hasMany(Order::class);
} }
public function tiers(): BelongsToMany
{
return $this->belongsToMany(Tier::class);
}
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
return $this->belongsToMany(User::class); return $this->belongsToMany(User::class);

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('tier_voucher', function (Blueprint $table) {
$table->id();
$table->foreignId('tier_id')->constrained()->cascadeOnDelete();
$table->foreignId('voucher_id')->constrained()->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tier_voucher');
}
};

View File

@ -4,6 +4,7 @@
use App\Enums\VoucherType; use App\Enums\VoucherType;
use App\Models\Outlet; use App\Models\Outlet;
use App\Models\Tier;
use App\Models\Voucher; use App\Models\Voucher;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@ -11,12 +12,16 @@ class VoucherSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
$tiers = Tier::all();
$tierIds = $tiers->pluck('id')->toArray();
$vouchers = [ $vouchers = [
[ [
'name' => 'Diskon 10%', 'name' => 'Diskon 10%',
'code' => 'DISC10', 'code' => 'DISC10',
'type' => VoucherType::PERCENTAGE, 'type' => VoucherType::PERCENTAGE,
'tags' => 'DISC 10%', 'tags' => 'DISC 10%',
'tier_ids' => [], // Voucher umum untuk semua tier
'summary' => 'Diskon 10% untuk pembelian minimal Rp50.000', 'summary' => 'Diskon 10% untuk pembelian minimal Rp50.000',
'discount_amount' => 10, 'discount_amount' => 10,
'min_purchase' => 50000, 'min_purchase' => 50000,
@ -32,6 +37,7 @@ public function run(): void
'code' => 'DISC20', 'code' => 'DISC20',
'type' => VoucherType::PERCENTAGE, 'type' => VoucherType::PERCENTAGE,
'tags' => 'DISC 20%', 'tags' => 'DISC 20%',
'tier_ids' => isset($tierIds[0]) ? [$tierIds[0]] : [], // Voucher khusus tier pertama
'summary' => 'Diskon 20% untuk pembelian minimal Rp100.000', 'summary' => 'Diskon 20% untuk pembelian minimal Rp100.000',
'discount_amount' => 20, 'discount_amount' => 20,
'min_purchase' => 100000, 'min_purchase' => 100000,
@ -44,9 +50,10 @@ public function run(): void
], ],
[ [
'name' => 'Potongan Rp25K', 'name' => 'Potongan Rp25K',
'code' => 'SAVE 25K', 'code' => 'SAVE25K',
'type' => VoucherType::FIXED, 'type' => VoucherType::FIXED,
'tags' => 'SAVE 25K', 'tags' => 'SAVE 25K',
'tier_ids' => [], // Voucher umum
'summary' => 'Potongan Rp25K untuk pembelian minimal Rp100.000', 'summary' => 'Potongan Rp25K untuk pembelian minimal Rp100.000',
'discount_amount' => 25000, 'discount_amount' => 25000,
'min_purchase' => 100000, 'min_purchase' => 100000,
@ -62,6 +69,7 @@ public function run(): void
'code' => 'SAVE50K', 'code' => 'SAVE50K',
'type' => VoucherType::FIXED, 'type' => VoucherType::FIXED,
'tags' => 'SAVE 50K', 'tags' => 'SAVE 50K',
'tier_ids' => isset($tierIds[1]) ? [$tierIds[1]] : [], // Voucher khusus tier kedua
'summary' => 'Potongan Rp50K untuk pembelian minimal Rp200.000', 'summary' => 'Potongan Rp50K untuk pembelian minimal Rp200.000',
'discount_amount' => 50000, 'discount_amount' => 50000,
'min_purchase' => 200000, 'min_purchase' => 200000,
@ -77,6 +85,7 @@ public function run(): void
'code' => 'ONGKIR', 'code' => 'ONGKIR',
'type' => VoucherType::FIXED, 'type' => VoucherType::FIXED,
'tags' => 'GRATIS ONGKIR', 'tags' => 'GRATIS ONGKIR',
'tier_ids' => [], // Voucher umum
'summary' => 'Gratis Ongkir untuk pembelian minimal Rp50.000', 'summary' => 'Gratis Ongkir untuk pembelian minimal Rp50.000',
'discount_amount' => 15000, 'discount_amount' => 15000,
'min_purchase' => 50000, 'min_purchase' => 50000,
@ -92,6 +101,7 @@ public function run(): void
'code' => 'FLASH30', 'code' => 'FLASH30',
'type' => VoucherType::PERCENTAGE, 'type' => VoucherType::PERCENTAGE,
'tags' => 'FLASH 30%', 'tags' => 'FLASH 30%',
'tier_ids' => isset($tierIds[2]) ? [$tierIds[2]] : [], // Voucher khusus tier ketiga
'summary' => 'Diskon 30% untuk pembelian minimal Rp75.000', 'summary' => 'Diskon 30% untuk pembelian minimal Rp75.000',
'discount_amount' => 30, 'discount_amount' => 30,
'min_purchase' => 75000, 'min_purchase' => 75000,
@ -107,6 +117,7 @@ public function run(): void
'code' => 'MEMBER15', 'code' => 'MEMBER15',
'type' => VoucherType::PERCENTAGE, 'type' => VoucherType::PERCENTAGE,
'tags' => 'Serial Member', 'tags' => 'Serial Member',
'tier_ids' => [], // Voucher umum untuk semua member
'summary' => 'Diskon 15% untuk pembelian minimal Rp50.000', 'summary' => 'Diskon 15% untuk pembelian minimal Rp50.000',
'discount_amount' => 15, 'discount_amount' => 15,
'min_purchase' => 50000, 'min_purchase' => 50000,
@ -119,8 +130,12 @@ public function run(): void
]; ];
foreach ($vouchers as $voucher) { foreach ($vouchers as $voucher) {
$tierIdsToAttach = $voucher['tier_ids'];
unset($voucher['tier_ids']);
$newVoucher = Voucher::create($voucher); $newVoucher = Voucher::create($voucher);
$newVoucher->tiers()->attach($tierIdsToAttach);
$newVoucher->outlets()->attach(Outlet::inRandomOrder()->first()->id); $newVoucher->outlets()->attach(Outlet::inRandomOrder()->first()->id);
} }
} }

View File

@ -67,7 +67,13 @@ class="absolute top-0 right-0 w-24 h-24 bg-home-primary/10 rounded-full blur-2xl
class="inline-flex items-center gap-2 bg-white px-3 py-1 rounded-full border border-home-primary/10 shadow-sm mb-4"> class="inline-flex items-center gap-2 bg-white px-3 py-1 rounded-full border border-home-primary/10 shadow-sm mb-4">
<span class="w-2 h-2 rounded-full bg-home-primary"></span> <span class="w-2 h-2 rounded-full bg-home-primary"></span>
<span <span
class="text-xs font-bold text-home-primary uppercase tracking-wider">{{ $voucher->tags }}</span> class="text-xs font-bold text-home-primary uppercase tracking-wider">{{ $voucher->tags }}</span>
@if ($voucher->tiers->isNotEmpty())
<span class="w-1 h-1 rounded-full bg-home-primary/30"></span>
<span class="text-[10px] font-bold text-home-primary/60 uppercase tracking-wider">
Khusus {{ $voucher->tiers->take(2)->pluck('name')->join(', ') }}{{ $voucher->tiers->count() > 2 ? ' +'.($voucher->tiers->count() - 2) : '' }}
</span>
@endif
</div> </div>
<h3 <h3

View File

@ -30,7 +30,7 @@ class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap
</flux:heading> </flux:heading>
<flux:text class="mb-1"> <flux:text class="mb-1">
Min. Belanja {{ $voucher['min_purchase'] }} Min. Belanja {{ $voucher['min_purchase'] }} {{ $voucher['tier_name'] }}
</flux:text> </flux:text>
<div x-data="{ textToCopy: '{{ $voucher['code'] }}', copied: false }" <div x-data="{ textToCopy: '{{ $voucher['code'] }}', copied: false }"

View File

@ -41,22 +41,22 @@
</flux:field> </flux:field>
<flux:field> <flux:field>
<flux:label>Min. Belanja</flux:label> <flux:label>Min. Belanja</flux:label>
<flux:input.group> <flux:input.group>
<flux:input.group.prefix>Rp</flux:input.group.prefix> <flux:input.group.prefix>Rp</flux:input.group.prefix>
<flux:input placeholder="10.000" <flux:input placeholder="10.000"
x-mask:dynamic="$money($input, ',')" x-mask:dynamic="$money($input, ',')"
wire:model="form.min_purchase" autocomplete="off" /> wire:model="form.min_purchase" autocomplete="off" />
</flux:input.group> </flux:input.group>
<flux:error name="form.min_purchase" /> <flux:error name="form.min_purchase" />
</flux:field> </flux:field>
<flux:input label="Kuota" placeholder="100" wire:model="form.quota" <flux:input label="Kuota" placeholder="100" wire:model="form.quota"
x-mask:dynamic="$money($input, ',')" autocomplete="off" /> x-mask:dynamic="$money($input, ',')" autocomplete="off" />
<flux:input label="Batas Per Customer" placeholder="1" <flux:input label="Batas Per Customer" placeholder="1"
wire:model="form.limit_per_user" x-mask:dynamic="$money($input, ',')" wire:model="form.limit_per_user" x-mask:dynamic="$money($input, ',')"
autocomplete="off" /> autocomplete="off" />
</div> </div>
</div> </div>
@ -88,7 +88,7 @@
<flux:card class="space-y-2 p-6"> <flux:card class="space-y-2 p-6">
<flux:field> <flux:field>
<flux:label>Jumlah Diskon <span class="text-red-500 ms-1">*</span></flux:label> <flux:label>Jumlah Diskon <span class="text-red-500 ms-1">*</span></flux:label>
<flux:radio.group wire:model="form.type" variant="buttons" class="w-full *:flex-1"> <flux:radio.group wire:model.live="form.type" variant="buttons" class="w-full *:flex-1">
@foreach (\App\Enums\VoucherType::cases() as $item) @foreach (\App\Enums\VoucherType::cases() as $item)
<flux:radio value="{{ $item->value }}"> <flux:radio value="{{ $item->value }}">
{{ $item->label() }} {{ $item->label() }}
@ -142,6 +142,18 @@
</flux:select> </flux:select>
<flux:error name="form.outlet_ids" /> <flux:error name="form.outlet_ids" />
</flux:field> </flux:field>
<flux:field>
<flux:label>Tier</flux:label>
<flux:description>Jika tidak memilih, maka voucher berlaku tanpa membatasi tier.</flux:description>
<flux:select placeholder="Pilih Tier" wire:model="form.tier_ids" multiple searchable variant="listbox">
@foreach ($tiers as $key => $value)
<flux:select.option value="{{ $key }}">{{ $value }}</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.tier_ids" />
</flux:field>
</flux:card> </flux:card>
</div> </div>
</div> </div>