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(),
Column::make('Min. Belanja', 'min_purchase')
->format(fn ($value) => formatCurrencyNumber($value, 'Rp'))
->format(fn($value) => formatCurrencyNumber($value, 'Rp'))
->searchable()
->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')
->label(fn ($row, $column) => formatDiscount($row->discount_amount, $row->type))
->label(fn($row, $column) => formatDiscount($row->discount_amount, $row->type))
->searchable()
->sortable(),
Column::make('Maks. Diskon')
->label(fn ($row, $column) => formatCurrencyNumber($row->max_discount, 'Rp'))
->label(fn($row, $column) => formatCurrencyNumber($row->max_discount, 'Rp'))
->searchable()
->sortable(),
Column::make('Kuota', 'quota')
->format(fn ($value) => $value ? formatCurrencyNumber($value) : 'Tidak Terbatas')
->format(fn($value) => $value ? formatCurrencyNumber($value) : 'Tidak Terbatas')
->searchable()
->sortable(),
Column::make('Sisa Voucher', 'available_count')
->format(fn ($value) => $value ? formatCurrencyNumber($value) : 'Tidak Terbatas')
->format(fn($value) => $value ? formatCurrencyNumber($value) : 'Tidak Terbatas')
->searchable()
->sortable(),
Column::make('Batas Per Pembali', 'limit_per_user')
->format(fn ($value) => $value ? $value : 'Tidak Terbatas')
->format(fn($value) => $value ? $value : 'Tidak Terbatas')
->searchable()
->sortable(),
ArrayColumn::make('Outlet')
->data(
fn ($value, $row) => $row->outlets->map(fn ($outlet) => [
fn($value, $row) => $row->outlets->map(fn($outlet) => [
'name' => $outlet->name,
'stock' => formatCurrencyNumber($outlet->pivot->stock, ''),
])->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">
<span class="font-medium text-gray-700">{{ $value["name"] }}</span>
</div>', ['value' => $value]))
@ -134,11 +141,11 @@ public function columns(): array
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')
->with('outlets')
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', 'tiers'])
->whereHas(
'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 array $tier_ids = [];
public array $outlet_ids = [];
public function rules(): array
@ -73,6 +75,8 @@ public function rules(): array
'summary' => ['required', 'string', 'max:200'],
'start_date' => ['required', 'date', 'after:yesterday'],
'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.*' => Rule::exists('outlets', 'id'),
];
@ -92,6 +96,8 @@ public function validationAttributes(): array
'discount_amount' => 'Jumlah Diskon',
'start_date' => 'tanggal mulai',
'end_date' => 'tanggal selesai',
'tier_ids' => 'tier',
'tier_ids.*' => 'tier',
'outlet_ids' => 'outlet',
'outlet_ids.*' => 'outlet',
];
@ -99,7 +105,7 @@ public function validationAttributes(): array
public function setVoucher(Voucher $voucher): void
{
$voucher->load('outlets');
$voucher->load(['outlets', 'tiers']);
$this->voucher = $voucher;
@ -115,6 +121,8 @@ public function setVoucher(Voucher $voucher): void
$this->summary = $this->voucher->summary;
$this->start_date = $this->voucher->start_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();
}
@ -135,6 +143,7 @@ public function store(): void
$voucher = Voucher::create($data);
$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->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\Models\Outlet;
use App\Models\PushNotification;
use App\Models\Tier;
use App\Traits\Authorization\WithAuthorization;
use App\Traits\Components\WithOutletSelector;
use App\Traits\Components\WithToast;
@ -23,9 +24,12 @@ class Create extends Component
public array $outlets;
public array $tiers;
public function mount(): void
{
$this->outlets = Outlet::pluck('name', 'id')->toArray();
$this->tiers = Tier::pluck('name', 'id')->toArray();
}
public function save(): void

View File

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

View File

@ -5,6 +5,7 @@
use Dyrynda\Database\Support\CascadeSoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Veelasky\LaravelHashId\Eloquent\HashableId;
@ -48,6 +49,11 @@ public function rewards(): HasMany
return $this->hasMany(TierReward::class);
}
public function vouchers(): BelongsToMany
{
return $this->belongsToMany(Voucher::class);
}
public static function getHighestTier()
{
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());
}
#[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
{
return $this->belongsToMany(Outlet::class);
@ -63,6 +72,11 @@ public function orders(): HasMany
return $this->hasMany(Order::class);
}
public function tiers(): BelongsToMany
{
return $this->belongsToMany(Tier::class);
}
public function users(): BelongsToMany
{
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\Models\Outlet;
use App\Models\Tier;
use App\Models\Voucher;
use Illuminate\Database\Seeder;
@ -11,12 +12,16 @@ class VoucherSeeder extends Seeder
{
public function run(): void
{
$tiers = Tier::all();
$tierIds = $tiers->pluck('id')->toArray();
$vouchers = [
[
'name' => 'Diskon 10%',
'code' => 'DISC10',
'type' => VoucherType::PERCENTAGE,
'tags' => 'DISC 10%',
'tier_ids' => [], // Voucher umum untuk semua tier
'summary' => 'Diskon 10% untuk pembelian minimal Rp50.000',
'discount_amount' => 10,
'min_purchase' => 50000,
@ -32,6 +37,7 @@ public function run(): void
'code' => 'DISC20',
'type' => VoucherType::PERCENTAGE,
'tags' => 'DISC 20%',
'tier_ids' => isset($tierIds[0]) ? [$tierIds[0]] : [], // Voucher khusus tier pertama
'summary' => 'Diskon 20% untuk pembelian minimal Rp100.000',
'discount_amount' => 20,
'min_purchase' => 100000,
@ -44,9 +50,10 @@ public function run(): void
],
[
'name' => 'Potongan Rp25K',
'code' => 'SAVE 25K',
'code' => 'SAVE25K',
'type' => VoucherType::FIXED,
'tags' => 'SAVE 25K',
'tier_ids' => [], // Voucher umum
'summary' => 'Potongan Rp25K untuk pembelian minimal Rp100.000',
'discount_amount' => 25000,
'min_purchase' => 100000,
@ -62,6 +69,7 @@ public function run(): void
'code' => 'SAVE50K',
'type' => VoucherType::FIXED,
'tags' => 'SAVE 50K',
'tier_ids' => isset($tierIds[1]) ? [$tierIds[1]] : [], // Voucher khusus tier kedua
'summary' => 'Potongan Rp50K untuk pembelian minimal Rp200.000',
'discount_amount' => 50000,
'min_purchase' => 200000,
@ -77,6 +85,7 @@ public function run(): void
'code' => 'ONGKIR',
'type' => VoucherType::FIXED,
'tags' => 'GRATIS ONGKIR',
'tier_ids' => [], // Voucher umum
'summary' => 'Gratis Ongkir untuk pembelian minimal Rp50.000',
'discount_amount' => 15000,
'min_purchase' => 50000,
@ -92,6 +101,7 @@ public function run(): void
'code' => 'FLASH30',
'type' => VoucherType::PERCENTAGE,
'tags' => 'FLASH 30%',
'tier_ids' => isset($tierIds[2]) ? [$tierIds[2]] : [], // Voucher khusus tier ketiga
'summary' => 'Diskon 30% untuk pembelian minimal Rp75.000',
'discount_amount' => 30,
'min_purchase' => 75000,
@ -107,6 +117,7 @@ public function run(): void
'code' => 'MEMBER15',
'type' => VoucherType::PERCENTAGE,
'tags' => 'Serial Member',
'tier_ids' => [], // Voucher umum untuk semua member
'summary' => 'Diskon 15% untuk pembelian minimal Rp50.000',
'discount_amount' => 15,
'min_purchase' => 50000,
@ -119,8 +130,12 @@ public function run(): void
];
foreach ($vouchers as $voucher) {
$tierIdsToAttach = $voucher['tier_ids'];
unset($voucher['tier_ids']);
$newVoucher = Voucher::create($voucher);
$newVoucher->tiers()->attach($tierIdsToAttach);
$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">
<span class="w-2 h-2 rounded-full bg-home-primary"></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>
<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:text class="mb-1">
Min. Belanja {{ $voucher['min_purchase'] }}
Min. Belanja {{ $voucher['min_purchase'] }} {{ $voucher['tier_name'] }}
</flux:text>
<div x-data="{ textToCopy: '{{ $voucher['code'] }}', copied: false }"

View File

@ -41,22 +41,22 @@
</flux:field>
<flux:field>
<flux:label>Min. Belanja</flux:label>
<flux:input.group>
<flux:input.group.prefix>Rp</flux:input.group.prefix>
<flux:input placeholder="10.000"
x-mask:dynamic="$money($input, ',')"
wire:model="form.min_purchase" autocomplete="off" />
</flux:input.group>
<flux:error name="form.min_purchase" />
</flux:field>
<flux:label>Min. Belanja</flux:label>
<flux:input.group>
<flux:input.group.prefix>Rp</flux:input.group.prefix>
<flux:input placeholder="10.000"
x-mask:dynamic="$money($input, ',')"
wire:model="form.min_purchase" autocomplete="off" />
</flux:input.group>
<flux:error name="form.min_purchase" />
</flux:field>
<flux:input label="Kuota" placeholder="100" wire:model="form.quota"
x-mask:dynamic="$money($input, ',')" autocomplete="off" />
<flux:input label="Kuota" placeholder="100" wire:model="form.quota"
x-mask:dynamic="$money($input, ',')" autocomplete="off" />
<flux:input label="Batas Per Customer" placeholder="1"
wire:model="form.limit_per_user" x-mask:dynamic="$money($input, ',')"
autocomplete="off" />
<flux:input label="Batas Per Customer" placeholder="1"
wire:model="form.limit_per_user" x-mask:dynamic="$money($input, ',')"
autocomplete="off" />
</div>
</div>
@ -88,7 +88,7 @@
<flux:card class="space-y-2 p-6">
<flux:field>
<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)
<flux:radio value="{{ $item->value }}">
{{ $item->label() }}
@ -142,6 +142,18 @@
</flux:select>
<flux:error name="form.outlet_ids" />
</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>
</div>
</div>